Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fea95f373 | ||
|
|
3b07e2a3c1 | ||
|
|
af2ee38443 | ||
|
|
455059eebe | ||
|
|
3702527613 | ||
|
|
ce79275d41 | ||
|
|
54f004a56c | ||
|
|
36b806378f | ||
|
|
0fcc662464 | ||
|
|
913e3675ae | ||
|
|
0ed8f42029 | ||
|
|
1475f8b3c5 | ||
|
|
3a2438f566 | ||
|
|
1ffa069151 | ||
|
|
3d5ffa1cb6 |
@@ -0,0 +1,873 @@
|
|||||||
|
general-sentence-separator = { " " }
|
||||||
|
|
||||||
|
general-key-control = Control
|
||||||
|
general-key-shift = Shift
|
||||||
|
general-key-alt = Alt
|
||||||
|
general-key-option = Option
|
||||||
|
general-key-command = Command
|
||||||
|
option-or-alt =
|
||||||
|
{ PLATFORM() ->
|
||||||
|
[macos] { general-key-option }
|
||||||
|
*[other] { general-key-alt }
|
||||||
|
}
|
||||||
|
command-or-control =
|
||||||
|
{ PLATFORM() ->
|
||||||
|
[macos] { general-key-command }
|
||||||
|
*[other] { general-key-control }
|
||||||
|
}
|
||||||
|
return-or-enter =
|
||||||
|
{ PLATFORM() ->
|
||||||
|
[macos] Return
|
||||||
|
*[other] Enter
|
||||||
|
}
|
||||||
|
delete-or-backspace =
|
||||||
|
{ PLATFORM() ->
|
||||||
|
[macos] Delete
|
||||||
|
*[other] Backspace
|
||||||
|
}
|
||||||
|
-os-name =
|
||||||
|
{ PLATFORM() ->
|
||||||
|
[macos] macOS
|
||||||
|
[windows] Windows
|
||||||
|
*[other] Linux
|
||||||
|
}
|
||||||
|
|
||||||
|
general-print = Print
|
||||||
|
general-remove = Remove
|
||||||
|
general-add = Add
|
||||||
|
general-remind-me-later = Remind Me Later
|
||||||
|
general-dont-ask-again = Don’t Ask Again
|
||||||
|
general-choose-file = Choose File…
|
||||||
|
general-open-settings = Open Settings
|
||||||
|
general-settings = Settings…
|
||||||
|
general-help = Help
|
||||||
|
general-tag = Tag
|
||||||
|
general-got-it = Got It
|
||||||
|
general-done = Done
|
||||||
|
general-view-troubleshooting-instructions = View Troubleshooting Instructions
|
||||||
|
general-go-back = Go Back
|
||||||
|
general-accept = Accept
|
||||||
|
general-cancel = Cancel
|
||||||
|
cancel-button =
|
||||||
|
.label = { general-cancel }
|
||||||
|
general-show-in-library = Show in Library
|
||||||
|
general-restartApp = Restart { -app-name }
|
||||||
|
general-restartInTroubleshootingMode = Restart in Troubleshooting Mode
|
||||||
|
general-save = Save
|
||||||
|
general-clear = Clear
|
||||||
|
clear-button =
|
||||||
|
.label = { general-clear }
|
||||||
|
general-update = Update
|
||||||
|
general-reset-to-default = Reset to Default
|
||||||
|
general-back = Back
|
||||||
|
general-edit = Edit
|
||||||
|
general-cut = Cut
|
||||||
|
general-copy = Copy
|
||||||
|
general-paste = Paste
|
||||||
|
general-find = Find
|
||||||
|
general-delete = Delete
|
||||||
|
general-insert = Insert
|
||||||
|
general-and = and
|
||||||
|
general-et-al = et al.
|
||||||
|
general-previous = Previous
|
||||||
|
general-next = Next
|
||||||
|
general-learn-more = Learn More
|
||||||
|
general-more-information = More Information
|
||||||
|
general-warning = Warning
|
||||||
|
general-type-to-continue = Type “{ $text }” to continue.
|
||||||
|
general-continue = Continue
|
||||||
|
general-allow = Allow
|
||||||
|
general-always-allow = Always Allow
|
||||||
|
general-deny = Deny
|
||||||
|
|
||||||
|
general-red = Red
|
||||||
|
general-orange = Orange
|
||||||
|
general-yellow = Yellow
|
||||||
|
general-green = Green
|
||||||
|
general-teal = Teal
|
||||||
|
general-blue = Blue
|
||||||
|
general-purple = Purple
|
||||||
|
general-magenta = Magenta
|
||||||
|
general-violet = Violet
|
||||||
|
general-maroon = Maroon
|
||||||
|
general-gray = Gray
|
||||||
|
general-black = Black
|
||||||
|
|
||||||
|
general-loading = Loading…
|
||||||
|
|
||||||
|
db-checking-integrity = Checking database integrity…
|
||||||
|
db-repairing = Repairing database…
|
||||||
|
|
||||||
|
citation-style-label = Citation Style:
|
||||||
|
language-label = Language:
|
||||||
|
|
||||||
|
menu-custom-group-submenu =
|
||||||
|
.label = More Options…
|
||||||
|
|
||||||
|
menu-file-show-in-finder =
|
||||||
|
.label = Show in Finder
|
||||||
|
menu-file-show-file =
|
||||||
|
.label = Show File
|
||||||
|
menu-file-show-files =
|
||||||
|
.label = Show Files
|
||||||
|
menu-print =
|
||||||
|
.label = { general-print }
|
||||||
|
|
||||||
|
menu-density =
|
||||||
|
.label = Density
|
||||||
|
|
||||||
|
add-attachment = Add Attachment
|
||||||
|
new-note = New Note
|
||||||
|
|
||||||
|
menu-add-by-identifier =
|
||||||
|
.label = Add by Identifier…
|
||||||
|
|
||||||
|
menu-add-attachment =
|
||||||
|
.label = { add-attachment }
|
||||||
|
menu-add-standalone-file-attachment =
|
||||||
|
.label = Add File…
|
||||||
|
menu-add-standalone-linked-file-attachment =
|
||||||
|
.label = Add Link to File…
|
||||||
|
menu-add-child-file-attachment =
|
||||||
|
.label = Attach File…
|
||||||
|
menu-add-child-linked-file-attachment =
|
||||||
|
.label = Attach Link to File…
|
||||||
|
menu-add-child-linked-url-attachment =
|
||||||
|
.label = Attach Web Link…
|
||||||
|
|
||||||
|
menu-new-note =
|
||||||
|
.label = { new-note }
|
||||||
|
menu-new-standalone-note =
|
||||||
|
.label = New Standalone Note
|
||||||
|
menu-new-item-note =
|
||||||
|
.label = New Item Note
|
||||||
|
|
||||||
|
menu-restoreToLibrary =
|
||||||
|
.label = Restore to Library
|
||||||
|
menu-deletePermanently =
|
||||||
|
.label = Delete Permanently…
|
||||||
|
|
||||||
|
menu-tools-plugins =
|
||||||
|
.label = Plugins
|
||||||
|
|
||||||
|
menu-view-columns-move-left =
|
||||||
|
.label = Move Column Left
|
||||||
|
menu-view-columns-move-right =
|
||||||
|
.label = Move Column Right
|
||||||
|
menu-view-hide-context-annotation-rows =
|
||||||
|
.label = Hide Non-Matching Annotations
|
||||||
|
|
||||||
|
|
||||||
|
menu-view-note-font-size =
|
||||||
|
.label = Note Font Size
|
||||||
|
|
||||||
|
menu-view-note-tab-font-size =
|
||||||
|
.label = Note Tab Font Size
|
||||||
|
|
||||||
|
menu-show-tabs-menu =
|
||||||
|
.label = Show Tabs Menu
|
||||||
|
|
||||||
|
menu-edit-copy-annotation =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] Copy Annotation
|
||||||
|
*[other] Copy { $count } Annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
main-window-command =
|
||||||
|
.label = Library
|
||||||
|
main-window-key =
|
||||||
|
.key = L
|
||||||
|
|
||||||
|
zotero-toolbar-tabs-menu =
|
||||||
|
.tooltiptext = List all tabs
|
||||||
|
filter-collections = Filter Collections
|
||||||
|
zotero-collections-search =
|
||||||
|
.placeholder = { filter-collections }
|
||||||
|
zotero-collections-search-btn =
|
||||||
|
.tooltiptext = { filter-collections }
|
||||||
|
zotero-tabs-menu-filter =
|
||||||
|
.placeholder = Search Tabs
|
||||||
|
zotero-tabs-menu-close-button =
|
||||||
|
.title = Close Tab
|
||||||
|
zotero-toolbar-tabs-scroll-forwards =
|
||||||
|
.title = Scroll forwards
|
||||||
|
zotero-toolbar-tabs-scroll-backwards =
|
||||||
|
.title = Scroll backwards
|
||||||
|
|
||||||
|
toolbar-add-attachment =
|
||||||
|
.tooltiptext = { add-attachment }
|
||||||
|
|
||||||
|
recently-read = Recently Read
|
||||||
|
collections-menu-show-recently-read =
|
||||||
|
.label = Show { recently-read }
|
||||||
|
item-menu-remove-from-recently-read =
|
||||||
|
.label = Remove from { recently-read }…
|
||||||
|
collections-menu-clear-all-last-read =
|
||||||
|
.label = Clear All Last Read Dates…
|
||||||
|
recently-read-clear-all-confirm = All Last Read dates in this library will be erased.
|
||||||
|
|
||||||
|
# Item list section headers for a multiple-row collection-tree selection within one library
|
||||||
|
items-section-collections-selected =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $count } collection selected
|
||||||
|
*[other] { $count } collections selected
|
||||||
|
}
|
||||||
|
items-section-searches-selected =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $count } saved search selected
|
||||||
|
*[other] { $count } saved searches selected
|
||||||
|
}
|
||||||
|
items-section-sources-selected =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $count } source selected
|
||||||
|
*[other] { $count } sources selected
|
||||||
|
}
|
||||||
|
# Item list section headers for a selection spanning multiple libraries, one per library
|
||||||
|
items-section-library-collections =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $library } ({ $count } collection selected)
|
||||||
|
*[other] { $library } ({ $count } collections selected)
|
||||||
|
}
|
||||||
|
items-section-library-searches =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $library } ({ $count } saved search selected)
|
||||||
|
*[other] { $library } ({ $count } saved searches selected)
|
||||||
|
}
|
||||||
|
items-section-library-sources =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $library } ({ $count } source selected)
|
||||||
|
*[other] { $library } ({ $count } sources selected)
|
||||||
|
}
|
||||||
|
items-section-library-recently-read = { $library } ({ recently-read })
|
||||||
|
items-section-library = { $library }
|
||||||
|
collections-menu-rename =
|
||||||
|
.label = Rename
|
||||||
|
edit-saved-search = Edit Saved Search
|
||||||
|
collections-menu-edit-search =
|
||||||
|
.label = Edit Search
|
||||||
|
collections-menu-duplicate-search =
|
||||||
|
.label = Duplicate Search
|
||||||
|
collections-menu-move-collection =
|
||||||
|
.label = Move To
|
||||||
|
collections-menu-copy-collection =
|
||||||
|
.label = Copy To
|
||||||
|
collections-menu-export =
|
||||||
|
.label = Export…
|
||||||
|
collections-menu-generate-report =
|
||||||
|
.label = Generate Report…
|
||||||
|
collections-menu-create-bibliography =
|
||||||
|
.label = Create Bibliography…
|
||||||
|
collections-menu-unsubscribe =
|
||||||
|
.label = Unsubscribe…
|
||||||
|
collections-menu-delete =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] Delete Collection…
|
||||||
|
*[other] Delete Collections…
|
||||||
|
}
|
||||||
|
collections-menu-delete-with-items =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] Delete Collection and Items…
|
||||||
|
*[other] Delete Collections and Items…
|
||||||
|
}
|
||||||
|
collections-menu-delete-search =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] Delete Search…
|
||||||
|
*[other] Delete Searches…
|
||||||
|
}
|
||||||
|
|
||||||
|
collections-delete-title =
|
||||||
|
{ $count ->
|
||||||
|
[one] Delete Collection
|
||||||
|
*[other] Delete Collections
|
||||||
|
}
|
||||||
|
collections-delete-message =
|
||||||
|
{ $count ->
|
||||||
|
[one] Are you sure you want to delete this collection?
|
||||||
|
*[other] Are you sure you want to delete { $count } collections?
|
||||||
|
}
|
||||||
|
collections-delete-keep-items =
|
||||||
|
{ $count ->
|
||||||
|
[one] Items within this collection will not be deleted.
|
||||||
|
*[other] Items within these collections will not be deleted.
|
||||||
|
}
|
||||||
|
collections-delete-with-items-title =
|
||||||
|
{ $count ->
|
||||||
|
[one] Delete Collection and Items
|
||||||
|
*[other] Delete Collections and Items
|
||||||
|
}
|
||||||
|
collections-delete-with-items-message =
|
||||||
|
{ $count ->
|
||||||
|
[one] Are you sure you want to delete this collection and move all items within it to the Trash?
|
||||||
|
*[other] Are you sure you want to delete { $count } collections and move all items within them to the Trash?
|
||||||
|
}
|
||||||
|
collections-delete-search-title =
|
||||||
|
{ $count ->
|
||||||
|
[one] Delete Search
|
||||||
|
*[other] Delete Searches
|
||||||
|
}
|
||||||
|
collections-delete-search-message =
|
||||||
|
{ $count ->
|
||||||
|
[one] Are you sure you want to delete this search?
|
||||||
|
*[other] Are you sure you want to delete { $count } searches?
|
||||||
|
}
|
||||||
|
|
||||||
|
item-creator-moveDown =
|
||||||
|
.label = Move Down
|
||||||
|
item-creator-moveToTop =
|
||||||
|
.label = Move to Top
|
||||||
|
item-creator-moveUp =
|
||||||
|
.label = Move Up
|
||||||
|
|
||||||
|
item-menu-viewAttachment =
|
||||||
|
.label = Open { $numAttachments ->
|
||||||
|
[one] { $attachmentType ->
|
||||||
|
[pdf] PDF
|
||||||
|
[epub] EPUB
|
||||||
|
[snapshot] Snapshot
|
||||||
|
[note] Note
|
||||||
|
*[other] Attachment
|
||||||
|
}
|
||||||
|
*[other] { $attachmentType ->
|
||||||
|
[pdf] PDFs
|
||||||
|
[epub] EPUBs
|
||||||
|
[snapshot] Snapshots
|
||||||
|
[note] Notes
|
||||||
|
*[other] Attachments
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
$openIn ->
|
||||||
|
[tab] in New Tab
|
||||||
|
[window] in New Window
|
||||||
|
*[other] { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
item-menu-add-file =
|
||||||
|
.label = File
|
||||||
|
item-menu-add-linked-file =
|
||||||
|
.label = Linked File
|
||||||
|
item-menu-add-url =
|
||||||
|
.label = Web Link
|
||||||
|
item-menu-change-parent-item =
|
||||||
|
.label = Change Parent Item…
|
||||||
|
item-menu-relate-items =
|
||||||
|
.label = Relate Items
|
||||||
|
|
||||||
|
view-online = View Online
|
||||||
|
item-menu-option-view-online =
|
||||||
|
.label = {view-online}
|
||||||
|
item-button-view-online =
|
||||||
|
.tooltiptext = {view-online}
|
||||||
|
|
||||||
|
file-renaming-file-renamed-to = File renamed to { $filename }
|
||||||
|
|
||||||
|
itembox-button-options =
|
||||||
|
.tooltiptext = Open context menu
|
||||||
|
itembox-button-merge =
|
||||||
|
.aria-label = Select version of { $field } field
|
||||||
|
|
||||||
|
create-parent-intro = Enter a DOI, ISBN, PMID, arXiv ID, or ADS Bibcode to identify this file:
|
||||||
|
|
||||||
|
reader-use-dark-mode-for-content =
|
||||||
|
.label = Use Dark Mode for Content
|
||||||
|
|
||||||
|
update-updates-found-intro-minor = An update for { -app-name } is available:
|
||||||
|
update-updates-found-desc = It is recommended that you apply this update as soon as possible.
|
||||||
|
|
||||||
|
import-window =
|
||||||
|
.title = Import
|
||||||
|
|
||||||
|
import-where-from = Where do you want to import from?
|
||||||
|
import-online-intro-title = Introduction
|
||||||
|
|
||||||
|
import-source-file =
|
||||||
|
.label = A file (BibTeX, RIS, Zotero RDF, etc.)
|
||||||
|
|
||||||
|
import-source-folder =
|
||||||
|
.label = A folder of PDFs or other files
|
||||||
|
|
||||||
|
import-source-online =
|
||||||
|
.label = { $targetApp } online import
|
||||||
|
|
||||||
|
import-options = Options
|
||||||
|
import-importing = Importing…
|
||||||
|
|
||||||
|
import-create-collection =
|
||||||
|
.label = Place imported collections and items into new collection
|
||||||
|
|
||||||
|
import-recreate-structure =
|
||||||
|
.label = Recreate folder structure as collections
|
||||||
|
|
||||||
|
import-fileTypes-header = File Types to Import:
|
||||||
|
|
||||||
|
import-fileTypes-pdf =
|
||||||
|
.label = PDFs
|
||||||
|
|
||||||
|
import-fileTypes-other =
|
||||||
|
.placeholder = Other files by pattern, comma-separated (e.g., *.jpg,*.png)
|
||||||
|
|
||||||
|
import-file-handling = File Handling
|
||||||
|
import-file-handling-store =
|
||||||
|
.label = Copy files to the { -app-name } storage folder
|
||||||
|
import-file-handling-link =
|
||||||
|
.label = Link to files in original location
|
||||||
|
import-fileHandling-description = Linked files cannot be synced by { -app-name }.
|
||||||
|
import-online-new =
|
||||||
|
.label = Download new items only; don’t update previously imported items
|
||||||
|
import-mendeley-username = Username
|
||||||
|
import-mendeley-password = Password
|
||||||
|
|
||||||
|
general-error = Error
|
||||||
|
file-interface-import-error = An error occurred while trying to import the selected file. Please ensure that the file is valid and try again.
|
||||||
|
file-interface-import-complete = Import Complete
|
||||||
|
file-interface-items-were-imported = { $numItems ->
|
||||||
|
[0] No items were imported
|
||||||
|
[one] One item was imported
|
||||||
|
*[other] { $numItems } items were imported
|
||||||
|
}
|
||||||
|
file-interface-items-were-relinked = { $numRelinked ->
|
||||||
|
[0] No items were relinked
|
||||||
|
[one] One item was relinked
|
||||||
|
*[other] { $numRelinked } items were relinked
|
||||||
|
}
|
||||||
|
|
||||||
|
import-mendeley-cannot-decrypt = The selected Mendeley database could not be decrypted. This can happen if the database file has been renamed. See <a data-l10n-name="mendeley-import-kb">How do I import a Mendeley library into Zotero?</a> for more information.
|
||||||
|
import-mendeley-unsupported = The selected file does not appear to be a Mendeley database. See <a data-l10n-name="mendeley-import-kb">How do I import a Mendeley library into Zotero?</a> for more information.
|
||||||
|
import-mendeley-db-in-use = The selected Mendeley database is in use. Please quit Mendeley Desktop and try again.
|
||||||
|
|
||||||
|
file-interface-import-error-translator = An error occurred importing the selected file with “{ $translator }”. Please ensure that the file is valid and try again.
|
||||||
|
|
||||||
|
import-online-intro=In the next step you will be asked to log in to { $targetAppOnline } and grant { -app-name } access. This is necessary to import your { $targetApp } library into { -app-name }.
|
||||||
|
import-online-intro2={ -app-name } will never see or store your { $targetApp } password.
|
||||||
|
import-online-form-intro = Please enter your credentials to log in to { $targetAppOnline }. This is necessary to import your { $targetApp } library into { -app-name }.
|
||||||
|
import-online-wrong-credentials = Login to { $targetApp } failed. Please re-enter credentials and try again.
|
||||||
|
import-online-blocked-by-plugin = The import cannot continue with { $plugin } installed. Please disable this plugin and try again.
|
||||||
|
import-online-relink-only =
|
||||||
|
.label = Relink Mendeley Desktop citations
|
||||||
|
import-online-relink-kb = { general-more-information }
|
||||||
|
import-online-connection-error = { -app-name } could not connect to { $targetApp }. Please check your internet connection and try again.
|
||||||
|
|
||||||
|
tab-title-multiple-collections = Multiple
|
||||||
|
|
||||||
|
items-table-cell-notes =
|
||||||
|
.aria-label = { $count ->
|
||||||
|
[one] { $count } Note
|
||||||
|
*[other] { $count } Notes
|
||||||
|
}
|
||||||
|
|
||||||
|
items-column-added-by = Added By
|
||||||
|
items-column-modified-by = Modified By
|
||||||
|
items-column-last-read = Last Read
|
||||||
|
|
||||||
|
report-error =
|
||||||
|
.label = Report Error…
|
||||||
|
|
||||||
|
rtfScan-wizard =
|
||||||
|
.title = RTF Scan
|
||||||
|
|
||||||
|
rtfScan-introPage-description = { -app-name } can automatically extract and reformat citations and insert a bibliography into RTF files. It currently supports citations in variations of the following formats:
|
||||||
|
rtfScan-introPage-description2 = To get started, select an RTF input file and an output file below:
|
||||||
|
|
||||||
|
rtfScan-input-file = Input File:
|
||||||
|
rtfScan-output-file = Output File:
|
||||||
|
|
||||||
|
rtfScan-no-file-selected = No file selected
|
||||||
|
rtfScan-choose-input-file =
|
||||||
|
.label = { general-choose-file }
|
||||||
|
.aria-label = Choose Input File
|
||||||
|
rtfScan-choose-output-file =
|
||||||
|
.label = { general-choose-file }
|
||||||
|
.aria-label = Choose Output File
|
||||||
|
|
||||||
|
rtfScan-intro-page = Introduction
|
||||||
|
|
||||||
|
rtfScan-scan-page = Scanning for Citations
|
||||||
|
|
||||||
|
rtfScan-scanPage-description = { -app-name } is scanning your document for citations. Please be patient.
|
||||||
|
|
||||||
|
rtfScan-citations-page = Verify Cited Items
|
||||||
|
|
||||||
|
rtfScan-citations-page-description = Please review the list of recognized citations below to ensure that { -app-name } has selected the corresponding items correctly. Any unmapped or ambiguous citations must be resolved before proceeding to the next step.
|
||||||
|
|
||||||
|
rtfScan-style-page = Document Formatting
|
||||||
|
|
||||||
|
rtfScan-format-page = Formatting Citations
|
||||||
|
|
||||||
|
rtfScan-format-page-description = { -app-name } is processing and formatting your RTF file. Please be patient.
|
||||||
|
|
||||||
|
rtfScan-complete-page = RTF Scan Complete
|
||||||
|
|
||||||
|
rtfScan-complete-page-description = Your document has now been scanned and processed. Please ensure that it is formatted correctly.
|
||||||
|
rtfScan-action-find-match =
|
||||||
|
.title = Select matching item
|
||||||
|
|
||||||
|
rtfScan-action-accept-match =
|
||||||
|
.title = Accept this match
|
||||||
|
|
||||||
|
runJS-title = Run JavaScript
|
||||||
|
runJS-editor-label = Code:
|
||||||
|
runJS-run = Run
|
||||||
|
runJS-help = { general-help }
|
||||||
|
runJS-completed = completed successfully
|
||||||
|
runJS-result = {
|
||||||
|
$type ->
|
||||||
|
[async] Return value:
|
||||||
|
*[other] Result:
|
||||||
|
}
|
||||||
|
runJS-run-async = Run as async function
|
||||||
|
|
||||||
|
bibliography-window =
|
||||||
|
.title = { -app-name } - Create Citation/Bibliography
|
||||||
|
bibliography-style-label = { citation-style-label }
|
||||||
|
bibliography-locale-label = { language-label }
|
||||||
|
bibliography-displayAs-label = Display citations as:
|
||||||
|
bibliography-advancedOptions-label = Advanced Options
|
||||||
|
bibliography-outputMode-label = Output Mode:
|
||||||
|
bibliography-outputMode-citations =
|
||||||
|
.label = {
|
||||||
|
$type ->
|
||||||
|
[citation] Citations
|
||||||
|
[note] Notes
|
||||||
|
*[other] Citations
|
||||||
|
}
|
||||||
|
bibliography-outputMode-bibliography =
|
||||||
|
.label = Bibliography
|
||||||
|
bibliography-outputMethod-label = Output Method:
|
||||||
|
bibliography-outputMethod-saveAsRTF =
|
||||||
|
.label = Save as RTF
|
||||||
|
bibliography-outputMethod-saveAsHTML =
|
||||||
|
.label = Save as HTML
|
||||||
|
bibliography-outputMethod-copyToClipboard =
|
||||||
|
.label = Copy to Clipboard
|
||||||
|
bibliography-outputMethod-print =
|
||||||
|
.label = Print
|
||||||
|
bibliography-manageStyles-label = Manage Styles…
|
||||||
|
|
||||||
|
styleEditor-locatorType =
|
||||||
|
.aria-label = Locator type
|
||||||
|
styleEditor-locatorInput = Locator input
|
||||||
|
styleEditor-citationStyle = { citation-style-label }
|
||||||
|
styleEditor-locale = { language-label }
|
||||||
|
styleEditor-editor =
|
||||||
|
.aria-label = Style editor
|
||||||
|
styleEditor-preview =
|
||||||
|
.aria-label = Preview
|
||||||
|
|
||||||
|
stylePreview-generating = Generating previews…
|
||||||
|
|
||||||
|
publications-intro-page = My Publications
|
||||||
|
|
||||||
|
publications-intro = Items you add to My Publications will be shown on your profile page on zotero.org. If you choose to include attached files, they will be made publicly available under the license you specify. Only add work you yourself have created, and only include files if you have the rights to distribute them and wish to do so.
|
||||||
|
publications-include-checkbox-files =
|
||||||
|
.label = Include files
|
||||||
|
publications-include-checkbox-notes =
|
||||||
|
.label = Include notes
|
||||||
|
|
||||||
|
publications-include-adjust-at-any-time = You can adjust what to show at any time from the My Publications collection.
|
||||||
|
publications-intro-authorship =
|
||||||
|
.label = I created this work.
|
||||||
|
publications-intro-authorship-files =
|
||||||
|
.label = I created this work and have the rights to distribute included files.
|
||||||
|
|
||||||
|
publications-sharing-page = Choose how your work may be shared
|
||||||
|
|
||||||
|
publications-sharing-keep-rights-field =
|
||||||
|
.label = Keep the existing Rights field
|
||||||
|
publications-sharing-keep-rights-field-where-available =
|
||||||
|
.label = Keep the existing Rights field where available
|
||||||
|
publications-sharing-text = You can reserve all rights to your work, license it under a Creative Commons license, or dedicate it to the public domain. In all cases, the work will be made publicly available via zotero.org.
|
||||||
|
publications-sharing-prompt = Would you like to allow your work to be shared by others?
|
||||||
|
publications-sharing-reserved =
|
||||||
|
.label = No, only publish my work on zotero.org
|
||||||
|
publications-sharing-cc =
|
||||||
|
.label = Yes, under a Creative Commons license
|
||||||
|
publications-sharing-cc0 =
|
||||||
|
.label = Yes, and place my work in the public domain
|
||||||
|
|
||||||
|
publications-license-page = Choose a Creative Commons license
|
||||||
|
publications-choose-license-text = A Creative Commons license allows others to copy and redistribute your work as long as they give appropriate credit, provide a link to the license, and indicate if changes were made. Additional conditions can be specified below.
|
||||||
|
publications-choose-license-adaptations-prompt = Allow adaptations of your work to be shared?
|
||||||
|
|
||||||
|
publications-choose-license-yes =
|
||||||
|
.label = Yes
|
||||||
|
.accesskey = Y
|
||||||
|
publications-choose-license-no =
|
||||||
|
.label = No
|
||||||
|
.accesskey = N
|
||||||
|
publications-choose-license-sharealike =
|
||||||
|
.label = Yes, as long as others share alike
|
||||||
|
.accesskey = S
|
||||||
|
|
||||||
|
publications-choose-license-commercial-prompt = Allow commercial uses of your work?
|
||||||
|
publications-buttons-add-to-my-publications =
|
||||||
|
.label = Add to My Publications
|
||||||
|
publications-buttons-next-sharing =
|
||||||
|
.label = Next: Sharing
|
||||||
|
publications-buttons-next-choose-license =
|
||||||
|
.label = Choose a License
|
||||||
|
|
||||||
|
licenses-cc-0 = CC0 1.0 Universal Public Domain Dedication
|
||||||
|
licenses-cc-by = Creative Commons Attribution 4.0 International License
|
||||||
|
licenses-cc-by-nd = Creative Commons Attribution-NoDerivatives 4.0 International License
|
||||||
|
licenses-cc-by-sa = Creative Commons Attribution-ShareAlike 4.0 International License
|
||||||
|
licenses-cc-by-nc = Creative Commons Attribution-NonCommercial 4.0 International License
|
||||||
|
licenses-cc-by-nc-nd = Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License
|
||||||
|
licenses-cc-by-nc-sa = Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License
|
||||||
|
licenses-cc-more-info = Be sure you have read the Creative Commons <a data-l10n-name="license-considerations">Considerations for licensors</a> before placing your work under a CC license. Note that the license you apply cannot be revoked, even if you later choose different terms or cease publishing the work.
|
||||||
|
licenses-cc0-more-info = Be sure you have read the Creative Commons <a data-l10n-name="license-considerations">CC0 FAQ</a> before applying CC0 to your work. Please note that dedicating your work to the public domain is irreversible, even if you later choose different terms or cease publishing the work.
|
||||||
|
|
||||||
|
debug-output-logging-restart-in-troubleshooting-mode-checkbox = { general-restartInTroubleshootingMode }
|
||||||
|
|
||||||
|
restart-in-troubleshooting-mode-menuitem =
|
||||||
|
.label = Restart in Troubleshooting Mode…
|
||||||
|
.accesskey = T
|
||||||
|
restart-in-troubleshooting-mode-dialog-title = { general-restartInTroubleshootingMode }
|
||||||
|
restart-in-troubleshooting-mode-dialog-description = { -app-name } will restart with all plugins disabled. Some features may not function correctly while Troubleshooting Mode is enabled.
|
||||||
|
|
||||||
|
menu-ui-density =
|
||||||
|
.label = Density
|
||||||
|
menu-ui-density-comfortable =
|
||||||
|
.label = Comfortable
|
||||||
|
menu-ui-density-compact =
|
||||||
|
.label = Compact
|
||||||
|
|
||||||
|
pane-item-details = Item Details
|
||||||
|
pane-info = Info
|
||||||
|
pane-abstract = Abstract
|
||||||
|
pane-attachments = Attachments
|
||||||
|
pane-notes = Notes
|
||||||
|
pane-note-info = Note Info
|
||||||
|
pane-libraries-collections = Libraries and Collections
|
||||||
|
pane-tags = Tags
|
||||||
|
pane-related = Related
|
||||||
|
pane-attachment-info = Attachment Info
|
||||||
|
pane-attachment-preview = Preview
|
||||||
|
pane-attachment-annotations = Annotations
|
||||||
|
|
||||||
|
pane-header-attachment-associated =
|
||||||
|
.label = Rename associated file
|
||||||
|
|
||||||
|
item-details-pane =
|
||||||
|
.aria-label = { pane-item-details }
|
||||||
|
section-info =
|
||||||
|
.label = { pane-info }
|
||||||
|
section-abstract =
|
||||||
|
.label = { pane-abstract }
|
||||||
|
section-attachments =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] { $count } Attachment
|
||||||
|
*[other] { $count } Attachments
|
||||||
|
}
|
||||||
|
section-attachment-preview =
|
||||||
|
.label = { pane-attachment-preview }
|
||||||
|
section-attachments-annotations =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] { $count } Annotation
|
||||||
|
*[other] { $count } Annotations
|
||||||
|
}
|
||||||
|
section-attachments-move-to-trash-message = Are you sure you want to move “{ $title }” to the trash?
|
||||||
|
section-notes =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] { $count } Note
|
||||||
|
*[other] { $count } Notes
|
||||||
|
}
|
||||||
|
section-libraries-collections =
|
||||||
|
.label = { pane-libraries-collections }
|
||||||
|
section-tags =
|
||||||
|
.label = { $count ->
|
||||||
|
[one] { $count } Tag
|
||||||
|
*[other] { $count } Tags
|
||||||
|
}
|
||||||
|
section-related =
|
||||||
|
.label = { $count } Related
|
||||||
|
section-attachment-info =
|
||||||
|
.label = { pane-attachment-info }
|
||||||
|
|
||||||
|
section-button-remove =
|
||||||
|
.tooltiptext = { general-remove }
|
||||||
|
section-button-add =
|
||||||
|
.tooltiptext = { general-add }
|
||||||
|
section-button-expand =
|
||||||
|
.dynamic-tooltiptext = Expand section
|
||||||
|
.label = Expand { $section } section
|
||||||
|
section-button-collapse =
|
||||||
|
.dynamic-tooltiptext = Collapse section
|
||||||
|
.label = Collapse { $section } section
|
||||||
|
annotations-count =
|
||||||
|
{ $count ->
|
||||||
|
[one] { $count } Annotation
|
||||||
|
*[other] { $count } Annotations
|
||||||
|
}
|
||||||
|
section-button-annotations =
|
||||||
|
.title = { annotations-count }
|
||||||
|
.aria-label = { annotations-count }
|
||||||
|
attachment-preview =
|
||||||
|
.aria-label = { pane-attachment-preview }
|
||||||
|
|
||||||
|
sidenav-info =
|
||||||
|
.tooltiptext = { pane-info }
|
||||||
|
sidenav-abstract =
|
||||||
|
.tooltiptext = { pane-abstract }
|
||||||
|
sidenav-attachments =
|
||||||
|
.tooltiptext = { pane-attachments }
|
||||||
|
sidenav-notes =
|
||||||
|
.tooltiptext = { pane-notes }
|
||||||
|
sidenav-note-info =
|
||||||
|
.tooltiptext = { pane-note-info }
|
||||||
|
sidenav-attachment-info =
|
||||||
|
.tooltiptext = { pane-attachment-info }
|
||||||
|
sidenav-attachment-preview =
|
||||||
|
.tooltiptext = { pane-attachment-preview }
|
||||||
|
sidenav-attachment-annotations =
|
||||||
|
.tooltiptext = { pane-attachment-annotations }
|
||||||
|
sidenav-libraries-collections =
|
||||||
|
.tooltiptext = { pane-libraries-collections }
|
||||||
|
sidenav-tags =
|
||||||
|
.tooltiptext = { pane-tags }
|
||||||
|
sidenav-related =
|
||||||
|
.tooltiptext = { pane-related }
|
||||||
|
sidenav-main-btn-grouping =
|
||||||
|
.aria-label = { pane-item-details }
|
||||||
|
sidenav-reorder-up =
|
||||||
|
.label = Move Section Up
|
||||||
|
sidenav-reorder-down =
|
||||||
|
.label = Move Section Down
|
||||||
|
sidenav-reorder-reset =
|
||||||
|
.label = Reset Section Order
|
||||||
|
|
||||||
|
toggle-item-pane =
|
||||||
|
.tooltiptext = Toggle Item Pane
|
||||||
|
toggle-context-pane =
|
||||||
|
.tooltiptext = Toggle Context Pane
|
||||||
|
|
||||||
|
pin-section =
|
||||||
|
.label = Pin Section
|
||||||
|
unpin-section =
|
||||||
|
.label = Unpin Section
|
||||||
|
|
||||||
|
collapse-other-sections =
|
||||||
|
.label = Collapse Other Sections
|
||||||
|
expand-all-sections =
|
||||||
|
.label = Expand All Sections
|
||||||
|
|
||||||
|
abstract-field =
|
||||||
|
.placeholder = Add abstract…
|
||||||
|
|
||||||
|
tag-field =
|
||||||
|
.aria-label = { general-tag }
|
||||||
|
|
||||||
|
tagselector-search =
|
||||||
|
.placeholder = Filter Tags
|
||||||
|
|
||||||
|
context-notes-search =
|
||||||
|
.placeholder = Search Notes
|
||||||
|
context-notes-return-button =
|
||||||
|
.aria-label = { general-go-back }
|
||||||
|
|
||||||
|
new-collection = New Collection…
|
||||||
|
menu-new-collection =
|
||||||
|
.label = { new-collection }
|
||||||
|
toolbar-new-collection =
|
||||||
|
.tooltiptext = { new-collection }
|
||||||
|
|
||||||
|
new-collection-dialog =
|
||||||
|
.title = New Collection
|
||||||
|
.buttonlabelaccept = Create Collection
|
||||||
|
new-collection-name = Name:
|
||||||
|
new-collection-create-in = Create in:
|
||||||
|
|
||||||
|
show-publications-menuitem =
|
||||||
|
.label = Show My Publications
|
||||||
|
|
||||||
|
attachment-info-title = Title
|
||||||
|
attachment-info-filename = Filename
|
||||||
|
attachment-info-accessed = Accessed
|
||||||
|
attachment-info-pages = Pages
|
||||||
|
attachment-info-modified = Modified
|
||||||
|
attachment-info-index = Indexed
|
||||||
|
attachment-info-convert-note =
|
||||||
|
.label = Migrate to {
|
||||||
|
$type ->
|
||||||
|
[standalone] Standalone
|
||||||
|
[child] Item
|
||||||
|
*[unknown] New
|
||||||
|
} Note
|
||||||
|
.tooltiptext = Adding notes to attachments is no longer supported, but you can edit this note by migrating it to a separate note.
|
||||||
|
|
||||||
|
section-note-info =
|
||||||
|
.label = { pane-note-info }
|
||||||
|
|
||||||
|
note-info-title = Title
|
||||||
|
note-info-parent-item = Parent Item
|
||||||
|
note-info-parent-item-button = {
|
||||||
|
$hasParentItem ->
|
||||||
|
[true] { $parentItemTitle }
|
||||||
|
*[false] None
|
||||||
|
}
|
||||||
|
.title = {
|
||||||
|
$hasParentItem ->
|
||||||
|
[true] View parent item in library
|
||||||
|
*[false] View note item in library
|
||||||
|
}
|
||||||
|
note-info-date-created = Created
|
||||||
|
note-info-date-modified = Modified
|
||||||
|
note-info-size = Size
|
||||||
|
note-info-word-count = Word Count
|
||||||
|
note-info-character-count = Character Count
|
||||||
|
|
||||||
|
item-title-empty-note = Untitled Note
|
||||||
|
|
||||||
|
attachment-preview-placeholder = No attachment to preview
|
||||||
|
attachment-rename-from-parent =
|
||||||
|
.tooltiptext = Rename File to Match Parent Item
|
||||||
|
|
||||||
|
account-log-in = Log In
|
||||||
|
account-not-logged-in-text = Log in to your Zotero account to sync your data.
|
||||||
|
account-error-login-session-expired = Your login session has expired. Please try again.
|
||||||
|
|
||||||
|
|
||||||
|
toggle-preview =
|
||||||
|
.label = {
|
||||||
|
$type ->
|
||||||
|
[open] Hide
|
||||||
|
[collapsed] Show
|
||||||
|
*[unknown] Toggle
|
||||||
|
} Attachment Preview
|
||||||
|
|
||||||
|
annotation-image-not-available = [Image not available]
|
||||||
|
|
||||||
|
quicksearch-mode =
|
||||||
|
.aria-label = Quick Search mode
|
||||||
|
quicksearch-input =
|
||||||
|
.aria-label = Quick Search
|
||||||
|
.placeholder = { $placeholder }
|
||||||
|
.aria-description = { $placeholder }
|
||||||
|
|
||||||
|
advanced-search = Advanced Search
|
||||||
|
menuitem-advanced-search =
|
||||||
|
.label = { advanced-search }
|
||||||
|
quicksearch-advanced-search-button =
|
||||||
|
.tooltiptext = { advanced-search }
|
||||||
|
.aria-label = { advanced-search }
|
||||||
|
advanced-search-close =
|
||||||
|
.tooltiptext = Close Advanced Search
|
||||||
|
advanced-search-expand =
|
||||||
|
.tooltiptext = Expand Advanced Search
|
||||||
|
advanced-search-collapse =
|
||||||
|
.tooltiptext = Collapse Advanced Search
|
||||||
|
|
||||||
|
item-pane-header-view-as =
|
||||||
|
.label = View As
|
||||||
|
item-pane-header-none =
|
||||||
|
.label = None
|
||||||
|
item-pane-header-title =
|
||||||
|
.label = Title
|
||||||
|
item-pane-header-titleCreatorYear =
|
||||||
|
.label = Title, Creator, Year
|
||||||
|
item-pane-header-bibEntry =
|
||||||
|
.label = Bibliography Entry
|
||||||
|
item-pane-header-more-options =
|
||||||
|
.label = More Options
|
||||||
|
|
||||||
|
item-pane-message-items-selected = { $count ->
|
||||||
|
[0] No items selected
|
||||||
|
[one] {
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="context-fill" stroke="context-stroke">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<path d="M3.5 4.5h13a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-3.2 2.4a.5.5 0 0 1-.8-.4V14.5H3.5A1.5 1.5 0 0 1 2 13V6a1.5 1.5 0 0 1 1.5-1.5z"/>
|
<!-- ChatPapers: paper + chat — sidebar glyph -->
|
||||||
<circle cx="7" cy="9.5" r="1"/>
|
<path
|
||||||
<circle cx="10" cy="9.5" r="1"/>
|
fill="context-fill"
|
||||||
<circle cx="13" cy="9.5" r="1"/>
|
fill-opacity="0.92"
|
||||||
|
d="M3.2 2.8h7.4c.4 0 .7.15.95.4l2.05 2.1c.25.25.4.58.4.95V14.2c0 .9-.73 1.63-1.63 1.63H3.2c-.9 0-1.63-.73-1.63-1.63V4.43C1.57 3.53 2.3 2.8 3.2 2.8z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.35"
|
||||||
|
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="context-stroke"
|
||||||
|
stroke-opacity="0.55"
|
||||||
|
stroke-width="0.9"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M4.35 7.1h5.1M4.35 9.15h4.2M4.35 11.2h3.4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
d="M11.35 9.05h5.1c.85 0 1.55.7 1.55 1.55v3.35c0 .85-.7 1.55-1.55 1.55h-1.55l-1.55 1.25a.4.4 0 0 1-.65-.32v-.93h-1.35c-.85 0-1.55-.7-1.55-1.55v-3.35c0-.85.7-1.55 1.55-1.55z"
|
||||||
|
/>
|
||||||
|
<circle cx="12.55" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||||
|
<circle cx="13.95" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||||
|
<circle cx="15.35" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<!-- ChatPapers 语音伴读:论文 + 声波 + 耳机(与对话气泡图标区分) -->
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.92"
|
||||||
|
d="M3.2 2.8h7.4c.4 0 .7.15.95.4l2.05 2.1c.25.25.4.58.4.95V14.2c0 .9-.73 1.63-1.63 1.63H3.2c-.9 0-1.63-.73-1.63-1.63V4.43C1.57 3.53 2.3 2.8 3.2 2.8z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.35"
|
||||||
|
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
||||||
|
/>
|
||||||
|
<!-- 声波(替代对话图标的横线文本) -->
|
||||||
|
<rect x="4.4" y="8.35" width="1.15" height="2.8" rx="0.4" fill="context-fill" fill-opacity="0.5" />
|
||||||
|
<rect x="6.05" y="7.25" width="1.15" height="4.2" rx="0.4" fill="context-fill" fill-opacity="0.72" />
|
||||||
|
<rect x="7.7" y="8.65" width="1.15" height="2.2" rx="0.4" fill="context-fill" fill-opacity="0.5" />
|
||||||
|
<rect x="9.35" y="7.75" width="1.15" height="3.5" rx="0.4" fill="context-fill" fill-opacity="0.62" />
|
||||||
|
<!-- 耳机头梁 -->
|
||||||
|
<path
|
||||||
|
stroke="context-fill"
|
||||||
|
stroke-opacity="0.92"
|
||||||
|
stroke-width="1.15"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
d="M12.85 9.15c0-1.55 1.15-2.8 2.55-2.8s2.55 1.25 2.55 2.8"
|
||||||
|
/>
|
||||||
|
<!-- 左耳罩 -->
|
||||||
|
<rect
|
||||||
|
x="11.55"
|
||||||
|
y="9.05"
|
||||||
|
width="2.15"
|
||||||
|
height="3.55"
|
||||||
|
rx="0.85"
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.92"
|
||||||
|
/>
|
||||||
|
<!-- 右耳罩 -->
|
||||||
|
<rect
|
||||||
|
x="14.55"
|
||||||
|
y="9.05"
|
||||||
|
width="2.15"
|
||||||
|
height="3.55"
|
||||||
|
rx="0.85"
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.92"
|
||||||
|
/>
|
||||||
|
<!-- 向外声波弧线 -->
|
||||||
|
<path
|
||||||
|
stroke="context-stroke"
|
||||||
|
stroke-opacity="0.45"
|
||||||
|
stroke-width="0.85"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
d="M17.15 10.1c.55.45.9 1.05.9 1.75s-.35 1.3-.9 1.75"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="context-stroke"
|
||||||
|
stroke-opacity="0.3"
|
||||||
|
stroke-width="0.85"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
d="M17.85 10.1c.85.7 1.35 1.65 1.35 2.75s-.5 2.05-1.35 2.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Lucide FoldVertical, filled for Zotero toolbar buttons -->
|
||||||
|
<path d="M7.375 2.75h1.25v10.5h-1.25V2.75z" fill="context-fill"/>
|
||||||
|
<path d="M8 5.25 4.75 2h6.5L8 5.25z" fill="context-fill"/>
|
||||||
|
<path d="M8 10.75 4.75 14h6.5L8 10.75z" fill="context-fill"/>
|
||||||
|
<path d="M1.5 7.625h1.25v.75H1.5v-.75zm2.75 0h1.25v.75H4.25v-.75zm5.5 0H11v.75H9.75v-.75zm2.75 0h1.25v.75H13v-.75z" fill="context-fill" fill-opacity="0.82"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Lucide UnfoldVertical, filled for Zotero toolbar buttons -->
|
||||||
|
<path d="M7.375 2.75h1.25v10.5h-1.25V2.75z" fill="context-fill"/>
|
||||||
|
<path d="M8 2.75 4.75 6h6.5L8 2.75z" fill="context-fill"/>
|
||||||
|
<path d="M8 13.25 4.75 10h6.5L8 13.25z" fill="context-fill"/>
|
||||||
|
<path d="M1.5 7.625h1.25v.75H1.5v-.75zm2.75 0h1.25v.75H4.25v-.75zm5.5 0H11v.75H9.75v-.75zm2.75 0h1.25v.75H13v-.75z" fill="context-fill" fill-opacity="0.82"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 524 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Lucide AppWindow, filled for Zotero toolbar buttons -->
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 2.5h10a1.5 1.5 0 0 1 1.5 1.5v8.5a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 12.5V4A1.5 1.5 0 0 1 3 2.5Zm0 2.5v7.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V5H3Zm9-2H4a.5.5 0 0 0-.5.5V4.5h9V3a.5.5 0 0 0-.5-.5ZM5.25 2.5h1V4h-1V2.5Z" fill="context-fill"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 465 B |
@@ -116,6 +116,86 @@
|
|||||||
</hbox>
|
</hbox>
|
||||||
</groupbox>
|
</groupbox>
|
||||||
|
|
||||||
|
<groupbox>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-lecture"></html:h2></label>
|
||||||
|
<html:p id="chatpapers-pref-tts-hint" class="chatpapers-pref-hint"></html:p>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-tts-provider"
|
||||||
|
data-l10n-id="prefs-tts-provider"
|
||||||
|
></html:label>
|
||||||
|
<html:select id="chatpapers-pref-tts-provider" preference="ttsProvider">
|
||||||
|
</html:select>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-tts-voice"
|
||||||
|
data-l10n-id="prefs-tts-voice"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-tts-voice"
|
||||||
|
preference="ttsVoice"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<vbox id="chatpapers-pref-piper-box" hidden="true">
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-piper-path"
|
||||||
|
data-l10n-id="prefs-piper-path"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-piper-path"
|
||||||
|
preference="piperPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-piper-model"
|
||||||
|
data-l10n-id="prefs-piper-model"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-piper-model"
|
||||||
|
preference="piperModelPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
</vbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-mineru-path"
|
||||||
|
data-l10n-id="prefs-mineru-path"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-mineru-path"
|
||||||
|
preference="mineruPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-lecture-data-dir"
|
||||||
|
data-l10n-id="prefs-lecture-data-dir"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-lecture-data-dir"
|
||||||
|
preference="lectureDataDir"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<html:p
|
||||||
|
id="chatpapers-pref-lecture-data-dir-hint"
|
||||||
|
class="chatpapers-pref-hint"
|
||||||
|
data-l10n-id="prefs-lecture-data-dir-hint"
|
||||||
|
></html:p>
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
<groupbox>
|
<groupbox>
|
||||||
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
|
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
|
||||||
<html:label data-l10n-id="prefs-system-prompt"></html:label>
|
<html:label data-l10n-id="prefs-system-prompt"></html:label>
|
||||||
|
|||||||
@@ -2,8 +2,18 @@ startup-begin = ChatPapers is loading
|
|||||||
startup-finish = ChatPapers is ready
|
startup-finish = ChatPapers is ready
|
||||||
prefs-title = ChatPapers
|
prefs-title = ChatPapers
|
||||||
|
|
||||||
item-section-chat-head = ChatPapers
|
item-section-chat-head =
|
||||||
item-section-chat-sidenav = Chat with paper
|
.label = ChatPapers
|
||||||
|
item-section-chat-sidenav =
|
||||||
|
.tooltiptext = Chat with paper
|
||||||
|
item-pane-open-window =
|
||||||
|
.tooltiptext = Open in separate window
|
||||||
|
item-pane-adaptive-height =
|
||||||
|
.tooltiptext = Toggle adaptive height
|
||||||
|
item-pane-adaptive-height-on =
|
||||||
|
.tooltiptext = Adaptive height (on) — click to use fixed height
|
||||||
|
item-pane-adaptive-height-off =
|
||||||
|
.tooltiptext = Fixed height (on) — click to use adaptive height
|
||||||
|
|
||||||
chat-loading = Loading ChatPapers…
|
chat-loading = Loading ChatPapers…
|
||||||
chat-placeholder = Ask a question about this paper…
|
chat-placeholder = Ask a question about this paper…
|
||||||
@@ -27,12 +37,12 @@ chat-copied = Copied to clipboard
|
|||||||
chat-empty-title = Start a conversation
|
chat-empty-title = Start a conversation
|
||||||
chat-empty-desc = Summarize this paper or ask a question about methods, results, and limitations.
|
chat-empty-desc = Summarize this paper or ask a question about methods, results, and limitations.
|
||||||
|
|
||||||
itemmenu-chat = ChatPapers: Open chat
|
itemmenu-chat = ChatPapers: Chat
|
||||||
itemmenu-compare = ChatPapers: Compare papers
|
itemmenu-compare = ChatPapers: Compare papers
|
||||||
|
|
||||||
multi-title = Multi-paper chat
|
multi-title = ChatPapers chat
|
||||||
multi-empty-title = Read papers together
|
multi-empty-title = Paper chat
|
||||||
multi-empty-desc = Enter a research topic and click Topic review for writing-ready notes; or compare, summarize each, or ask freely.
|
multi-empty-desc = Ask about one paper, or click Add papers to bring more from your library for comparison and topic review.
|
||||||
multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation
|
multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation
|
||||||
multi-topic-review = Topic review
|
multi-topic-review = Topic review
|
||||||
multi-need-topic = Enter a research topic in the box, then click Topic review
|
multi-need-topic = Enter a research topic in the box, then click Topic review
|
||||||
@@ -41,6 +51,96 @@ multi-summary-each = Summarize each
|
|||||||
multi-common = Common themes
|
multi-common = Common themes
|
||||||
multi-close = Close
|
multi-close = Close
|
||||||
multi-no-items = Please select items first
|
multi-no-items = Please select items first
|
||||||
multi-need-two = Select at least 2 items to compare
|
multi-need-two = Add at least 2 papers for comparison actions (use Add papers)
|
||||||
multi-too-many = { $count } items selected; using the first 8
|
multi-too-many = Up to 8 papers; currently { $count }
|
||||||
multi-ready = { $count } papers ready for topic review
|
multi-ready = { $count } paper(s) loaded — chat away, or Add papers to expand
|
||||||
|
multi-add-paper = Add papers
|
||||||
|
multi-remove-paper = Remove from chat
|
||||||
|
multi-add-none = No new papers added (may already be in the chat)
|
||||||
|
multi-paper-added = Added { $added }; now { $count } papers
|
||||||
|
multi-paper-removed = Removed; now { $count } papers
|
||||||
|
|
||||||
|
cite-style = Citation style
|
||||||
|
cite-copy = Copy bibliography
|
||||||
|
cite-export = Export file
|
||||||
|
cite-loading = Loading styles…
|
||||||
|
cite-empty = No styles available
|
||||||
|
cite-copied = Bibliography copied
|
||||||
|
cite-exported = Bibliography exported
|
||||||
|
cite-export-cancel = Export cancelled
|
||||||
|
|
||||||
|
item-section-lecture-head =
|
||||||
|
.label = Voice lecture
|
||||||
|
item-section-lecture-sidenav =
|
||||||
|
.tooltiptext = Voice lecture
|
||||||
|
|
||||||
|
lecture-loading = Loading voice lecture…
|
||||||
|
lecture-mode-label = Voice lecture · Prepare then listen
|
||||||
|
lecture-empty-title = AI lecture mode
|
||||||
|
lecture-empty-desc = One-click prep: overview and paragraph-by-paragraph listening, with typed Q&A and spoken answers.
|
||||||
|
lecture-start = Start preparation
|
||||||
|
lecture-status-idle = Click Start preparation — AI reads the full paper first
|
||||||
|
lecture-status-preparing = Preparing lecture…
|
||||||
|
lecture-status-ready = Lecture ready ✓
|
||||||
|
lecture-status-ready-phase1 = Preparation done (Phase 1: summary ready)
|
||||||
|
lecture-status-ready-step1 = Ready: { $count } segments — tap a segment or press Play
|
||||||
|
lecture-status-failed = Preparation incomplete
|
||||||
|
lecture-beat-list-title = Overview lecture
|
||||||
|
lecture-beat-list-hint = Click a segment for details; click ▶ to play
|
||||||
|
lecture-beat-detail-empty = Select a segment above to view the full script
|
||||||
|
lecture-beat-label = Segment { $n }
|
||||||
|
lecture-play-start = Play
|
||||||
|
lecture-play-pause = Pause
|
||||||
|
lecture-play-next = Next
|
||||||
|
lecture-play-prev = Previous
|
||||||
|
lecture-play-complete = Playback finished
|
||||||
|
lecture-beat-tts-pending = Synthesizing
|
||||||
|
lecture-beat-tts-failed = Failed
|
||||||
|
lecture-reprepare = Re-prepare
|
||||||
|
lecture-save-note = Save note
|
||||||
|
lecture-note-saved = Lecture note saved to Zotero
|
||||||
|
lecture-mode-overview = Overview
|
||||||
|
lecture-mode-paragraph = Paragraph reading
|
||||||
|
lecture-paragraph-list-title = Paper paragraphs (original, then translation)
|
||||||
|
lecture-para-list-hint = Click a paragraph for details; click ▶ to play
|
||||||
|
lecture-para-detail-empty = Select a paragraph above to view original, translation, and context
|
||||||
|
lecture-para-play = Play this part
|
||||||
|
lecture-para-label = Para { $n }
|
||||||
|
lecture-para-original = Original
|
||||||
|
lecture-para-translation = Translation
|
||||||
|
lecture-para-context = Context
|
||||||
|
lecture-granularity-paragraph = By paragraph
|
||||||
|
lecture-granularity-sentence = By sentence
|
||||||
|
lecture-sentence-list-title = Sentences (original, then explanation)
|
||||||
|
lecture-sentence-label = P{ $p }·S{ $s }
|
||||||
|
lecture-sentence-list-hint = Sentences in this paragraph
|
||||||
|
lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing.
|
||||||
|
lecture-explain-selection = Explain & read selection
|
||||||
|
lecture-explain-selection-short = Explain
|
||||||
|
lecture-selection-empty = Select a sentence in the PDF first
|
||||||
|
lecture-selection-explaining = Generating explanation for selection…
|
||||||
|
lecture-selection-done = Selection explanation ready
|
||||||
|
lecture-selection-no-item = Cannot resolve the current PDF item
|
||||||
|
lecture-selection-title = Selection explain
|
||||||
|
lecture-selection-syntax = Syntax / terms
|
||||||
|
lecture-selection-context = Context role
|
||||||
|
lecture-selection-replay = Replay explanation
|
||||||
|
lecture-playing-en = Playing: original
|
||||||
|
lecture-playing-zh = Playing: translation
|
||||||
|
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
|
||||||
|
lecture-no-pdf = No PDF attachment on this item
|
||||||
|
lecture-attach-error = Cannot read PDF attachment
|
||||||
|
lecture-attach-error-detail = Cannot read PDF attachment: { $detail }
|
||||||
|
lecture-pdf-not-local = PDF is not available locally. Open the PDF in Zotero first, or ensure the attachment is synced or linked to a local file.
|
||||||
|
lecture-pdf-file-missing = Local PDF file is missing or the path is invalid. Check Zotero storage settings or re-link the file.
|
||||||
|
lecture-pdf-read-failed = Cannot read PDF file contents. Check that the file is valid and readable.
|
||||||
|
lecture-mount-error = Failed to load voice lecture pane
|
||||||
|
lecture-platform-hint-mac = macOS: set MinerU path under Preferences → ChatPapers → Voice lecture, or leave empty to use mineru on PATH.
|
||||||
|
lecture-platform-hint-win = Windows: set the full path to mineru.exe under Preferences → ChatPapers → Voice lecture.
|
||||||
|
lecture-platform-hint-other = Configure MinerU path in Preferences to enable structured PDF parsing.
|
||||||
|
|
||||||
|
lecture-tts-provider = TTS: { $provider }
|
||||||
|
lecture-tts-test = Test voice
|
||||||
|
lecture-tts-testing = Synthesizing test speech…
|
||||||
|
lecture-tts-test-done = Speech ready — playing
|
||||||
|
lecture-storage-write-failed = Cannot write voice lecture data to { $path }. { $detail } Set “Data directory” under Edit → Settings → ChatPapers → Voice lecture (must be writable).
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
itemmenu-label = ChatPapers: Summarize
|
itemmenu-label = ChatPapers
|
||||||
itemmenu-chat = ChatPapers: Open chat
|
itemmenu-chat = ChatPapers: Chat
|
||||||
itemmenu-compare = ChatPapers: Compare papers
|
itemmenu-compare = ChatPapers: Compare papers
|
||||||
|
|
||||||
|
item-section-chat-head =
|
||||||
|
.label = ChatPapers
|
||||||
|
item-section-chat-sidenav =
|
||||||
|
.tooltiptext = Chat with paper
|
||||||
|
item-section-lecture-head =
|
||||||
|
.label = Voice lecture
|
||||||
|
item-section-lecture-sidenav =
|
||||||
|
.tooltiptext = Voice lecture
|
||||||
|
|||||||
@@ -27,3 +27,13 @@ prefs-hint-cloud = Cloud provider: paper text is sent to the selected API endpoi
|
|||||||
prefs-models-empty = No models returned.
|
prefs-models-empty = No models returned.
|
||||||
prefs-models-pick = Enter a model id from the list (showing up to 30):
|
prefs-models-pick = Enter a model id from the list (showing up to 30):
|
||||||
prefs-privacy = Privacy: ChatPapers only sends content when you click Send/Summarize, and only to the endpoint you configured. API keys are stored in local Zotero preferences.
|
prefs-privacy = Privacy: ChatPapers only sends content when you click Send/Summarize, and only to the endpoint you configured. API keys are stored in local Zotero preferences.
|
||||||
|
|
||||||
|
prefs-section-lecture = Voice lecture
|
||||||
|
prefs-tts-provider = TTS engine
|
||||||
|
prefs-tts-voice = Voice (optional)
|
||||||
|
prefs-piper-path = Piper executable
|
||||||
|
prefs-piper-model = Piper model path (.onnx)
|
||||||
|
prefs-mineru-path = MinerU path
|
||||||
|
prefs-lecture-data-dir = Data directory
|
||||||
|
prefs-lecture-data-dir-hint = Leave empty to use chatpapers/lecture under your Zotero profile. If writes fail, set a writable absolute path, e.g. D:\ChatPapers\lecture-data
|
||||||
|
prefs-tts-hint-empty = Choose a TTS engine. Local engines are fully offline — no API key needed.
|
||||||
|
|||||||
@@ -2,8 +2,18 @@ startup-begin = ChatPapers 加载中
|
|||||||
startup-finish = ChatPapers 已就绪
|
startup-finish = ChatPapers 已就绪
|
||||||
prefs-title = ChatPapers
|
prefs-title = ChatPapers
|
||||||
|
|
||||||
item-section-chat-head = ChatPapers
|
item-section-chat-head =
|
||||||
item-section-chat-sidenav = 论文对话
|
.label = ChatPapers
|
||||||
|
item-section-chat-sidenav =
|
||||||
|
.tooltiptext = 论文对话
|
||||||
|
item-pane-open-window =
|
||||||
|
.tooltiptext = 在独立窗口中打开
|
||||||
|
item-pane-adaptive-height =
|
||||||
|
.tooltiptext = 切换自适应高度
|
||||||
|
item-pane-adaptive-height-on =
|
||||||
|
.tooltiptext = 自适应高度(已开启)— 点击切换为固定高度
|
||||||
|
item-pane-adaptive-height-off =
|
||||||
|
.tooltiptext = 固定高度(已开启)— 点击切换为自适应高度
|
||||||
|
|
||||||
chat-loading = 正在加载 ChatPapers…
|
chat-loading = 正在加载 ChatPapers…
|
||||||
chat-placeholder = 就这篇论文提问…
|
chat-placeholder = 就这篇论文提问…
|
||||||
@@ -27,12 +37,12 @@ chat-copied = 已复制到剪贴板
|
|||||||
chat-empty-title = 开始对话
|
chat-empty-title = 开始对话
|
||||||
chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。
|
chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。
|
||||||
|
|
||||||
itemmenu-chat = ChatPapers:打开对话
|
itemmenu-chat = ChatPapers:对话
|
||||||
itemmenu-compare = ChatPapers:多篇对照
|
itemmenu-compare = ChatPapers:多篇对照
|
||||||
|
|
||||||
multi-title = 多篇对照
|
multi-title = ChatPapers 对话
|
||||||
multi-empty-title = 多论文整体阅读
|
multi-empty-title = 文献对话
|
||||||
multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。
|
multi-empty-desc = 单篇可直接提问;点「添加文献」从文库加入更多论文后可对比与主题综述。
|
||||||
multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用
|
multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用
|
||||||
multi-topic-review = 主题综述
|
multi-topic-review = 主题综述
|
||||||
multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」
|
multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」
|
||||||
@@ -41,7 +51,96 @@ multi-summary-each = 各自摘要
|
|||||||
multi-common = 共同主题
|
multi-common = 共同主题
|
||||||
multi-close = 关闭
|
multi-close = 关闭
|
||||||
multi-no-items = 请先选择文献
|
multi-no-items = 请先选择文献
|
||||||
multi-need-two = 请至少选择 2 篇文献再对照
|
multi-need-two = 请至少加入 2 篇文献后再做对比类操作(可点「添加文献」)
|
||||||
multi-too-many = 已选择 { $count } 篇,将使用前 8 篇
|
multi-too-many = 最多支持 8 篇,当前已选 { $count } 篇
|
||||||
multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述
|
multi-ready = 已载入 { $count } 篇文献,可开始对话;可用「添加文献」扩充
|
||||||
|
multi-add-paper = 添加文献
|
||||||
|
multi-remove-paper = 移出对话
|
||||||
|
multi-add-none = 未加入新文献(可能已在对话中)
|
||||||
|
multi-paper-added = 已加入 { $added } 篇,当前共 { $count } 篇
|
||||||
|
multi-paper-removed = 已移出,当前共 { $count } 篇
|
||||||
|
|
||||||
|
cite-style = 引用样式
|
||||||
|
cite-copy = 复制参考文献
|
||||||
|
cite-export = 导出文件
|
||||||
|
cite-loading = 加载样式…
|
||||||
|
cite-empty = 无可用样式
|
||||||
|
cite-copied = 参考文献已复制到剪贴板
|
||||||
|
cite-exported = 参考文献已导出
|
||||||
|
cite-export-cancel = 已取消导出
|
||||||
|
|
||||||
|
item-section-lecture-head =
|
||||||
|
.label = 语音伴读
|
||||||
|
item-section-lecture-sidenav =
|
||||||
|
.tooltiptext = 语音伴读
|
||||||
|
|
||||||
|
lecture-loading = 正在加载语音伴读…
|
||||||
|
lecture-mode-label = 语音伴读 · 先备课再听
|
||||||
|
lecture-empty-title = AI 老师式伴读
|
||||||
|
lecture-empty-desc = 一键备课:整体讲解与逐段精读,随时文字提问并获得语音回答。
|
||||||
|
lecture-start = 开始备课
|
||||||
|
lecture-status-idle = 点击「开始备课」,AI 将先读完整篇论文
|
||||||
|
lecture-status-preparing = 正在备课…
|
||||||
|
lecture-status-ready = 老师已备课 ✓
|
||||||
|
lecture-status-ready-phase1 = 备课完成(Phase 1:摘要已生成)
|
||||||
|
lecture-status-ready-step1 = 备课完成:{ $count } 段讲解可播放,点击下方段落或播放按钮开始听课
|
||||||
|
lecture-status-failed = 备课未完成,请重试或检查 LLM 设置
|
||||||
|
lecture-beat-list-title = 整体讲解
|
||||||
|
lecture-beat-list-hint = 点击讲解段查看详情,点击 ▶ 开始朗读
|
||||||
|
lecture-beat-detail-empty = 点击上方讲解段查看完整讲稿
|
||||||
|
lecture-beat-label = 讲解 { $n }
|
||||||
|
lecture-play-start = 播放
|
||||||
|
lecture-play-pause = 暂停
|
||||||
|
lecture-play-next = 下一段
|
||||||
|
lecture-play-prev = 上一段
|
||||||
|
lecture-play-complete = 本轮讲解播放完毕
|
||||||
|
lecture-beat-tts-pending = 合成中
|
||||||
|
lecture-beat-tts-failed = 失败
|
||||||
|
lecture-reprepare = 重新备课
|
||||||
|
lecture-save-note = 保存笔记
|
||||||
|
lecture-note-saved = 伴读笔记已写入 Zotero 子笔记
|
||||||
|
lecture-mode-overview = 整体讲解
|
||||||
|
lecture-mode-paragraph = 逐段精读
|
||||||
|
lecture-paragraph-list-title = 论文段落(先读原文,再听译文)
|
||||||
|
lecture-para-list-hint = 点击段落查看详情,点击 ▶ 开始朗读
|
||||||
|
lecture-para-detail-empty = 点击上方段落查看原文、译文与上下文
|
||||||
|
lecture-para-play = 朗读此段
|
||||||
|
lecture-para-label = 段落 { $n }
|
||||||
|
lecture-para-original = 原文
|
||||||
|
lecture-para-translation = 译文 / 讲解
|
||||||
|
lecture-para-context = 上下文衔接
|
||||||
|
lecture-granularity-paragraph = 按段
|
||||||
|
lecture-granularity-sentence = 按句
|
||||||
|
lecture-sentence-list-title = 逐句精读(先读原文,再听讲解)
|
||||||
|
lecture-sentence-label = 段{ $p }·句{ $s }
|
||||||
|
lecture-sentence-list-hint = 本段各句
|
||||||
|
lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。
|
||||||
|
lecture-explain-selection = 解释朗读选区
|
||||||
|
lecture-explain-selection-short = 解释朗读
|
||||||
|
lecture-selection-empty = 请先在 PDF 中选中要解释的句子
|
||||||
|
lecture-selection-explaining = 正在生成选区讲解…
|
||||||
|
lecture-selection-done = 选区讲解完成
|
||||||
|
lecture-selection-no-item = 无法识别当前 PDF 条目
|
||||||
|
lecture-selection-title = 选区讲解
|
||||||
|
lecture-selection-syntax = 句法/术语
|
||||||
|
lecture-selection-context = 上下文作用
|
||||||
|
lecture-selection-replay = 重播讲解
|
||||||
|
lecture-playing-en = 正在播放:原文朗读
|
||||||
|
lecture-playing-zh = 正在播放:中文讲解
|
||||||
|
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
|
||||||
|
lecture-no-pdf = 当前条目没有可用的 PDF 附件
|
||||||
|
lecture-attach-error = 无法读取 PDF 附件
|
||||||
|
lecture-attach-error-detail = 无法读取 PDF 附件:{ $detail }
|
||||||
|
lecture-pdf-not-local = PDF 未下载到本地。请先在 Zotero 中双击打开该 PDF,或确认附件已同步/已链接到本机文件。
|
||||||
|
lecture-pdf-file-missing = PDF 本地文件不存在或路径无效。请检查 Zotero 存储设置,或重新关联 PDF 文件。
|
||||||
|
lecture-pdf-read-failed = 无法读取 PDF 文件内容。请确认文件未损坏,且 Zotero 有读取权限。
|
||||||
|
lecture-mount-error = 语音伴读面板加载失败
|
||||||
|
lecture-platform-hint-mac = macOS:MinerU 路径可在偏好设置 → ChatPapers → 语音伴读 中配置;留空则尝试 PATH 中的 mineru。
|
||||||
|
lecture-platform-hint-win = Windows:请在偏好设置中填写 MinerU 可执行文件完整路径(如 mineru.exe)。
|
||||||
|
lecture-platform-hint-other = 请在偏好设置中配置 MinerU 路径以启用 PDF 结构化解析。
|
||||||
|
|
||||||
|
lecture-tts-provider = TTS 引擎:{ $provider }
|
||||||
|
lecture-tts-test = 试听语音
|
||||||
|
lecture-tts-testing = 正在合成测试语音…
|
||||||
|
lecture-tts-test-done = 语音合成完成,正在播放
|
||||||
|
lecture-storage-write-failed = 无法写入语音伴读数据目录:{ $path }。{ $detail } 请在 编辑 → 设置 → ChatPapers → 语音伴读 中配置「数据存储目录」(需有写入权限)。
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
itemmenu-label = ChatPapers:总结
|
itemmenu-label = ChatPapers
|
||||||
itemmenu-chat = ChatPapers:打开对话
|
itemmenu-chat = ChatPapers:对话
|
||||||
itemmenu-compare = ChatPapers:多篇对照
|
itemmenu-compare = ChatPapers:多篇对照
|
||||||
|
|
||||||
|
item-section-chat-head =
|
||||||
|
.label = ChatPapers
|
||||||
|
item-section-chat-sidenav =
|
||||||
|
.tooltiptext = 论文对话
|
||||||
|
item-section-lecture-head =
|
||||||
|
.label = 语音伴读
|
||||||
|
item-section-lecture-sidenav =
|
||||||
|
.tooltiptext = 语音伴读
|
||||||
|
|||||||
@@ -27,3 +27,13 @@ prefs-hint-cloud = 云端 Provider:论文内容将发送到所选 API 端点
|
|||||||
prefs-models-empty = 未返回任何模型。
|
prefs-models-empty = 未返回任何模型。
|
||||||
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
|
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
|
||||||
prefs-privacy = 隐私说明:仅在你点击发送/总结时,向你配置的端点发送内容。API Key 仅保存在本地 Zotero 偏好设置中。
|
prefs-privacy = 隐私说明:仅在你点击发送/总结时,向你配置的端点发送内容。API Key 仅保存在本地 Zotero 偏好设置中。
|
||||||
|
|
||||||
|
prefs-section-lecture = 语音伴读
|
||||||
|
prefs-tts-provider = TTS 引擎
|
||||||
|
prefs-tts-voice = 音色(可选)
|
||||||
|
prefs-piper-path = Piper 可执行文件
|
||||||
|
prefs-piper-model = Piper 模型路径 (.onnx)
|
||||||
|
prefs-mineru-path = MinerU 路径
|
||||||
|
prefs-lecture-data-dir = 数据存储目录
|
||||||
|
prefs-lecture-data-dir-hint = 留空则使用 Zotero 配置文件夹下的 chatpapers/lecture。若写入失败,请填写有写入权限的绝对路径,例如 D:\ChatPapers\lecture-data
|
||||||
|
prefs-tts-hint-empty = 请选择 TTS 引擎。本地引擎完全离线,无需 API Key。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"id": "__addonID__",
|
"id": "__addonID__",
|
||||||
"update_url": "__updateURL__",
|
"update_url": "__updateURL__",
|
||||||
"strict_min_version": "9.0",
|
"strict_min_version": "9.0",
|
||||||
"strict_max_version": "9.*"
|
"strict_max_version": "10.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ pref("systemPrompt", "");
|
|||||||
pref("summaryPrompt", "");
|
pref("summaryPrompt", "");
|
||||||
pref("sendMetadata", true);
|
pref("sendMetadata", true);
|
||||||
pref("verboseLog", false);
|
pref("verboseLog", false);
|
||||||
|
pref("ttsProvider", "");
|
||||||
|
pref("ttsApiKey", "");
|
||||||
|
pref("ttsVoice", "");
|
||||||
|
pref("lectureTeacherStyle", "balanced");
|
||||||
|
pref("mineruPath", "");
|
||||||
|
pref("piperPath", "");
|
||||||
|
pref("piperModelPath", "");
|
||||||
|
pref("lectureDataDir", "");
|
||||||
|
pref("chatPaneAdaptiveHeight", true);
|
||||||
|
pref("lecturePaneAdaptiveHeight", true);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 786 KiB |
@@ -0,0 +1,680 @@
|
|||||||
|
# 软件架构设计文档
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | BanDu(伴读)— Zotero 插件 |
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 关联文档 | [PLAN.md](./PLAN.md)、[PRD.md](./PRD.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概述
|
||||||
|
|
||||||
|
### 1.1 架构风格
|
||||||
|
|
||||||
|
**Local-first 桌面插件架构**:核心计算与缓存均在用户机器完成;仅将段落文本与用户问题发送至用户配置的 LLM / TTS 服务。无自建后端(一期)。
|
||||||
|
|
||||||
|
### 1.2 设计原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 备课一次、缓存复用 | Lecture 持久化;attachment ID + 文件哈希为缓存键 |
|
||||||
|
| 增量交付 | 摘要 → beats → 逐段 → TTS 分阶段就绪,缩短 Time-to-Listen |
|
||||||
|
| UI / 领域 / 集成 分离 | 降低 Zotero API 变动影响 |
|
||||||
|
| Grounding 优先 | 生成内容必须可追溯到段落 ID |
|
||||||
|
| 优雅降级 | refs 无效、无 timestamps、无 BBT 均有 fallback |
|
||||||
|
|
||||||
|
### 1.3 系统上下文
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 用户 │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────┐
|
||||||
|
│ Zotero 7 (宿主) │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ BanDu 插件 │ │
|
||||||
|
│ │ UI │ Engine │ Store │ │
|
||||||
|
│ └─────────┬──────────────┘ │
|
||||||
|
└────────────┼────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────┐ ┌────────────┐ ┌─────────────┐
|
||||||
|
│ MinerU │ │ LLM API │ │ TTS API │
|
||||||
|
│ (本地子进程)│ │ / Ollama │ │ ElevenLabs等 │
|
||||||
|
└───────────┘ └────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ SQLite + │
|
||||||
|
│ mp3 文件 │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 逻辑分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer(表现层) │
|
||||||
|
│ • BanduPane(Item pane 伴读页) │
|
||||||
|
│ • LibraryWindow(伴读库) │
|
||||||
|
│ • SettingsPane(设置) │
|
||||||
|
│ • PlayerController(播放状态机 + <audio>) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Application Layer(应用层 / 用例) │
|
||||||
|
│ • PrepareLectureUseCase(备课编排) │
|
||||||
|
│ • PlayUseCase(播放 / 跳播 / 断点) │
|
||||||
|
│ • AskQuestionUseCase(问答 + RAG) │
|
||||||
|
│ • GenerateReportUseCase(报告 + Note 回写) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Domain Layer(领域层) │
|
||||||
|
│ • Paper, Paragraph, Chapter │
|
||||||
|
│ • Lecture, Beat, ParagraphExplanation │
|
||||||
|
│ • QARecord, Progress, Highlight │
|
||||||
|
│ • LecturePreparationState(状态机) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Infrastructure Layer(基础设施层) │
|
||||||
|
│ • ZoteroAdapter(Item / Attachment / Note) │
|
||||||
|
│ • PdfParser(MinerU 封装) │
|
||||||
|
│ • LlmClient(OpenAI-compatible / Ollama) │
|
||||||
|
│ • TtsClient(多提供商 + timestamps) │
|
||||||
|
│ • SqliteRepository │
|
||||||
|
│ • AudioStorage(mp3 路径管理) │
|
||||||
|
│ • RagIndex(段落检索) │
|
||||||
|
│ • PromptRegistry(模板版本管理) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模块说明
|
||||||
|
|
||||||
|
### 3.1 ZoteroAdapter
|
||||||
|
|
||||||
|
**职责**:封装所有 Zotero 7 API 调用。
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `getPdfAttachment(itemId)` | 返回主 PDF 路径、attachmentId |
|
||||||
|
| `computeFileHash(path)` | SHA-256 |
|
||||||
|
| `openPdfAtPage(itemId, page)` | 跳转 PDF 阅读器 |
|
||||||
|
| `createBanduNote(itemId, markdown)` | 创建伴读子笔记 |
|
||||||
|
| `getCitationKey(itemId)` | Better BibTeX 可选集成 |
|
||||||
|
| `listLibraryItems()` | 伴读库数据源 |
|
||||||
|
|
||||||
|
**约束**:UI 与用例层不得直接调用 `Zotero.*` 全局对象。
|
||||||
|
|
||||||
|
### 3.2 PdfParser
|
||||||
|
|
||||||
|
**职责**:调用 MinerU(一期),输出规范化段落树。
|
||||||
|
|
||||||
|
**输入**:PDF 本地路径
|
||||||
|
**输出**:`ParseResult { chapters[], paragraphs[], metadata }`
|
||||||
|
|
||||||
|
**段落字段**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Paragraph {
|
||||||
|
id: string; // 稳定 ID,如 "p-0017"
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: 'en' | 'zh';
|
||||||
|
bulletPoints?: string[]; // >150 词长段拆分要点
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败策略**:抛出 typed error(`ScannedPdfError`, `ParseTimeoutError`),UI 映射为用户文案。
|
||||||
|
|
||||||
|
### 3.3 PrepareLectureUseCase
|
||||||
|
|
||||||
|
**职责**:编排备课流水线,驱动增量就绪事件。
|
||||||
|
|
||||||
|
详见 §5 流水线与 §6 状态机。
|
||||||
|
|
||||||
|
### 3.4 LlmClient
|
||||||
|
|
||||||
|
**职责**:统一 LLM 调用;支持 streaming。
|
||||||
|
|
||||||
|
| 方法 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `generateSummary(paragraphs[])` | 全文摘要 |
|
||||||
|
| `generateBeats(summary, paragraphIndex[])` | 整体讲解 beats |
|
||||||
|
| `generateParagraphExplanation(para, summary, chapterTitle)` | 三层讲解 |
|
||||||
|
| `generateSentenceExplanation(sentence, context)` | 逐句讲解 |
|
||||||
|
| `answerQuestion(context, question)` | 问答 stream |
|
||||||
|
|
||||||
|
**配置**:provider、baseUrl、apiKey、model、temperature(默认 0.2–0.4)。
|
||||||
|
|
||||||
|
### 3.5 TtsClient
|
||||||
|
|
||||||
|
**职责**:文本 → mp3 + timestamps。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[]; // 可为空
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略**:优先 provider 原生 boundaries;缺失则 `HighlightGranularity = SENTENCE`。
|
||||||
|
|
||||||
|
### 3.6 RagIndex
|
||||||
|
|
||||||
|
**职责**:段落级检索,供问答组装上下文。
|
||||||
|
|
||||||
|
**一期实现**:SQLite FTS5 或 BM25;MVP 可不引入 embedding。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RagIndex {
|
||||||
|
build(paragraphs: Paragraph[]): void;
|
||||||
|
search(query: string, topK: number): Paragraph[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 SqliteRepository + AudioStorage
|
||||||
|
|
||||||
|
**职责**:结构化数据与二进制音频分离存储。
|
||||||
|
|
||||||
|
- SQLite:`{ZoteroDataDir}/bandu/bandu.db`
|
||||||
|
- 音频:`{ZoteroDataDir}/bandu/audio/{cacheKey}/{unitId}.mp3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PaperCache 1──* Paragraph
|
||||||
|
PaperCache 1──1 Lecture
|
||||||
|
Lecture 1──* Beat
|
||||||
|
Lecture 1──* ParagraphExplanation
|
||||||
|
PaperCache 1──* QARecord
|
||||||
|
PaperCache 1──1 Progress
|
||||||
|
PaperCache 1──* Highlight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 表结构(SQLite)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 论文缓存根表
|
||||||
|
CREATE TABLE paper_cache (
|
||||||
|
id TEXT PRIMARY KEY, -- hash(attachment_id + file_hash)
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT, -- 'en' | 'zh'
|
||||||
|
parse_status TEXT NOT NULL, --见状态机
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 章节
|
||||||
|
CREATE TABLE chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 段落
|
||||||
|
CREATE TABLE paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
chapter_id TEXT REFERENCES chapter(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 讲义
|
||||||
|
CREATE TABLE lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
summary_json TEXT NOT NULL, -- {problem, method, result, limitation}
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 整体讲解 beat
|
||||||
|
CREATE TABLE beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL, -- intro|method|experiment|conclusion|extension
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL, -- ["p-0012","p-0013"]
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL -- pending|ready|failed
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 逐段三层讲解
|
||||||
|
CREATE TABLE paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY REFERENCES paragraph(id),
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 播放进度
|
||||||
|
CREATE TABLE progress (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
last_mode TEXT, -- overview|paragraph
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT, -- 已听 unit id 列表
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 问答
|
||||||
|
CREATE TABLE qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
anchor_type TEXT NOT NULL, -- beat|paragraph|sentence|global
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 重点标记
|
||||||
|
CREATE TABLE highlight (
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
paragraph_id TEXT NOT NULL REFERENCES paragraph(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 段落 FTS(RAG)
|
||||||
|
CREATE VIRTUAL TABLE paragraph_fts USING fts5(
|
||||||
|
paragraph_id,
|
||||||
|
paper_id,
|
||||||
|
text,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 缓存键策略
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
|
||||||
|
失效条件:
|
||||||
|
• fileHash 变化(PDF 被替换)
|
||||||
|
• 用户「强制重新备课」
|
||||||
|
• prompt_version _major 升级(可选策略:仅影响新生成)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Lecture JSON 摘要结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"problem": "…",
|
||||||
|
"method": "…",
|
||||||
|
"result": "…",
|
||||||
|
"limitation": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Beat refs 校验算法
|
||||||
|
|
||||||
|
```
|
||||||
|
输入: beats[], validParagraphIds (Set)
|
||||||
|
对每个 beat:
|
||||||
|
beat.refs = beat.refs.filter(id => validParagraphIds.has(id))
|
||||||
|
if beat.refs.isEmpty:
|
||||||
|
beat.degraded = true // UI: 不展示原文高亮
|
||||||
|
输出: beats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 核心流水线
|
||||||
|
|
||||||
|
### 5.1 端到端数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
PDF path
|
||||||
|
→ [PdfParser] → paragraphs[] → SQLite
|
||||||
|
→ [LlmClient.generateSummary] → lecture.summary
|
||||||
|
→ [LlmClient.generateBeats] → beats[] → refs 校验 → SQLite
|
||||||
|
→ (event: beats_ready) → UI 可播整体模式
|
||||||
|
→ [LlmClient.generateParagraphExplanation] × N → SQLite
|
||||||
|
→ (event: paragraph_batch_ready)
|
||||||
|
→ [TtsClient.synthesize] × units → mp3 → SQLite
|
||||||
|
→ (event: unit_audio_ready)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 增量备课时序
|
||||||
|
|
||||||
|
```
|
||||||
|
时间 ──────────────────────────────────────────────►
|
||||||
|
|
||||||
|
解析 ████████
|
||||||
|
摘要 ███
|
||||||
|
beats ██████ ← beats_ready(~1-2min)
|
||||||
|
逐段讲解 ████████████████████
|
||||||
|
TTS(beats) ████████ ← 整体模式有声音
|
||||||
|
TTS(段落) █████████████████
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 问答上下文组装
|
||||||
|
|
||||||
|
```
|
||||||
|
AskContext {
|
||||||
|
question: string
|
||||||
|
anchor: { type, id, text, explanation? }
|
||||||
|
summary: SummaryJson
|
||||||
|
ragParagraphs: Paragraph[] // topK=5
|
||||||
|
paperTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
Prompt 模板顺序:
|
||||||
|
系统角色 → 全文摘要 → RAG 段落 → 锚点原文+讲解 → 用户问题
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 报告生成
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportBuilder.build(paperId):
|
||||||
|
summary ← lecture.summary
|
||||||
|
progress ← progress.listened_units
|
||||||
|
qas ← qa_record ORDER BY created_at
|
||||||
|
highlights ← highlight JOIN paragraph
|
||||||
|
bibtex ← ZoteroAdapter.getCitationKey + export
|
||||||
|
|
||||||
|
MarkdownRenderer → ZoteroAdapter.createBanduNote
|
||||||
|
```
|
||||||
|
|
||||||
|
**报告策略(建议 MVP)**:同一 Item 只保留一个「伴读报告」笔记;再次生成时**更新**同名 Note(`BanDu 伴读报告: {title}`),避免笔记爆炸。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 状态机
|
||||||
|
|
||||||
|
### 6.1 论文 / 备课状态(lecture_status)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐
|
||||||
|
│ idle │ 未开始
|
||||||
|
└────┬────┘
|
||||||
|
│ start
|
||||||
|
▼
|
||||||
|
┌─────────┐
|
||||||
|
┌────│ parsing │────┐ fail
|
||||||
|
│ └────┬────┘ │
|
||||||
|
│ │ success ▼
|
||||||
|
│ │ ┌─────────┐
|
||||||
|
│ └───►│ parsed │
|
||||||
|
│ └────┬────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
│ │ summarizing │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
│ │ beats_generating │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
│ │ beats_ready
|
||||||
|
│ ▼
|
||||||
|
│ ┌────────────────────┐
|
||||||
|
│ │ paragraphs_generating │◄──┐
|
||||||
|
│ └──────────┬─────────┘ │ batch loop
|
||||||
|
│ │ │
|
||||||
|
│ └─────────────┘
|
||||||
|
│ │ all para done
|
||||||
|
│ ▼
|
||||||
|
│ ┌────────────────────┐
|
||||||
|
│ │ tts_generating │
|
||||||
|
│ └──────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
└─────────────►│ ready │
|
||||||
|
error └──────────────┘
|
||||||
|
┌─────────┐
|
||||||
|
│ failed │
|
||||||
|
└─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**可恢复**:每个阶段完成后持久化;重启插件从 `lecture_status` 继续,跳过已完成步骤。
|
||||||
|
|
||||||
|
### 6.2 播放器状态
|
||||||
|
|
||||||
|
```
|
||||||
|
PlayerState {
|
||||||
|
mode: 'overview' | 'paragraph'
|
||||||
|
currentUnitId: string
|
||||||
|
isPlaying: boolean
|
||||||
|
playbackRate: 1 | 1.5 | 2
|
||||||
|
highlightCursorMs: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳播**:切换 `currentUnitId` → 加载对应 mp3 → seek(0) → 更新 Progress。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 接口设计(内部事件)
|
||||||
|
|
||||||
|
插件内使用 EventBus 解耦 UI 与长时间任务:
|
||||||
|
|
||||||
|
| 事件 | 载荷 | 订阅者 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `parse:progress` | `{ percent, message }` | BanduPane |
|
||||||
|
| `lecture:beats_ready` | `{ paperId, beatCount }` | BanduPane, Player |
|
||||||
|
| `lecture:paragraph_ready` | `{ paperId, paragraphId }` | BanduPane |
|
||||||
|
| `tts:unit_ready` | `{ unitId, audioPath }` | Player |
|
||||||
|
| `lecture:failed` | `{ paperId, error }` | BanduPane |
|
||||||
|
| `qa:chunk` | `{ textDelta }` | QAPanel |
|
||||||
|
| `qa:complete` | `{ qaRecordId }` | QAPanel, Report |
|
||||||
|
|
||||||
|
**长时间任务**:在 Zotero 主线程外使用 Web Worker 或子进程(MinerU);LLM/TTS 使用 async fetch,避免阻塞 UI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 外部集成
|
||||||
|
|
||||||
|
### 8.1 LLM
|
||||||
|
|
||||||
|
| 提供商 | 协议 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| OpenAI 兼容 API | HTTPS SSE | 备课 + 问答 |
|
||||||
|
| Ollama | localhost HTTP | 全本地备选 |
|
||||||
|
|
||||||
|
### 8.2 TTS
|
||||||
|
|
||||||
|
| 提供商 | 选型维度 |
|
||||||
|
|--------|----------|
|
||||||
|
| 火山 / Azure / MiniMax / ElevenLabs | 中文自然度、价格、word timestamps |
|
||||||
|
|
||||||
|
**抽象接口**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsProvider {
|
||||||
|
name: string;
|
||||||
|
synthesize(text: string, options: TtsOptions): Promise<TtsResult>;
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 MinerU
|
||||||
|
|
||||||
|
- **集成方式(待 Phase 0 确认)**:子进程 CLI,stdin/stdout JSON
|
||||||
|
- **打包**:插件安装包内含 MinerU 二进制或安装脚本;文档说明 Python 环境依赖
|
||||||
|
|
||||||
|
### 8.4 Better BibTeX(可选)
|
||||||
|
|
||||||
|
- 检测 `Zotero.BetterBibTeX` 是否存在
|
||||||
|
- 不存在:从 Item 字段生成简易 BibTeX 或留空字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全与隐私
|
||||||
|
|
||||||
|
| 项 | 方案 |
|
||||||
|
|----|------|
|
||||||
|
| PDF 文件 | 不离开本地 |
|
||||||
|
| 发往 LLM 的数据 | 段落 text + 用户问题 + 摘要;设置页明示 |
|
||||||
|
| API Key | Zotero prefs 加密存储或 OS Keychain |
|
||||||
|
| 日志 | 不记录 Key、不完整记录段落正文(可截断) |
|
||||||
|
| Ollama | 默认 localhost,无外网 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 部署与构建
|
||||||
|
|
||||||
|
### 10.1 技术栈
|
||||||
|
|
||||||
|
| 层 | 选型 |
|
||||||
|
|----|------|
|
||||||
|
| 插件框架 | [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template) |
|
||||||
|
| 语言 | TypeScript |
|
||||||
|
| UI | HTML + CSS(Fluent 风格贴近 Zotero) |
|
||||||
|
| 音频 | HTML5 `<audio>` |
|
||||||
|
| 数据库 | better-sqlite3 或 Zotero 内置 SQL(待 POC) |
|
||||||
|
| PDF | MinerU(Python,本地 CPU) |
|
||||||
|
|
||||||
|
### 10.2 目录结构(建议)
|
||||||
|
|
||||||
|
```
|
||||||
|
bandu/
|
||||||
|
├── addon/
|
||||||
|
│ ├── bootstrap.js
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ └── prefs.js
|
||||||
|
├── src/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── BanduPane.tsx
|
||||||
|
│ │ ├── LibraryWindow.tsx
|
||||||
|
│ │ └── SettingsPane.tsx
|
||||||
|
│ ├── application/
|
||||||
|
│ │ ├── PrepareLectureUseCase.ts
|
||||||
|
│ │ ├── PlayUseCase.ts
|
||||||
|
│ │ ├── AskQuestionUseCase.ts
|
||||||
|
│ │ └── GenerateReportUseCase.ts
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── infrastructure/
|
||||||
|
│ │ ├── zotero/
|
||||||
|
│ │ ├── pdf/
|
||||||
|
│ │ ├── llm/
|
||||||
|
│ │ ├── tts/
|
||||||
|
│ │ └── storage/
|
||||||
|
│ └── prompts/
|
||||||
|
├── tests/
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 版本兼容
|
||||||
|
|
||||||
|
- `manifest.json` 声明 `applications.zotero.strict_min_version` / `strict_max_version`
|
||||||
|
- CI 固定 Zotero 7.x 版本做 smoke test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 0 技术验证(Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | Item pane 嵌入自定义页 | 可展示 UI、读取当前 Item | 是 |
|
||||||
|
| P0-2 | 读取 PDF attachment 路径 | 路径有效、哈希可算 | 是 |
|
||||||
|
| P0-3 | `<audio>` 长音频播放 | 10 min+ 无泄漏/崩溃 | 是 |
|
||||||
|
| P0-4 | MinerU 解析 3 篇论文 | 段落切分可用率 ≥ 80% | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 | 自然度可接受 | 是 |
|
||||||
|
| P0-6 | TTS timestamps | 有则记录格式;无则确认句子级降级 | 否(降级) |
|
||||||
|
| P0-7 | Zotero Note Markdown | 报告渲染可接受 | 否 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 性能与扩展
|
||||||
|
|
||||||
|
### 12.1 长论文策略
|
||||||
|
|
||||||
|
| 页数 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| ≤ 30 | 全量逐段备课 |
|
||||||
|
| 30–80 | 逐段 lazy:按章节或用户点击生成 |
|
||||||
|
| > 80 | 默认仅整体讲解;逐段需用户确认(成本提示) |
|
||||||
|
|
||||||
|
### 12.2 并发控制
|
||||||
|
|
||||||
|
- LLM 备课:beats 串行或小批量并行(≤ 3)避免 rate limit
|
||||||
|
- TTS:队列化,优先 beats 音频
|
||||||
|
- 同一 paper 不允许重复触发备课(互斥锁)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 可观测性
|
||||||
|
|
||||||
|
| 类型 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 结构化日志 | paperId, stage, durationMs, errorCode |
|
||||||
|
| 用户可见进度 | 解析 %、beats 数、段落 i/n、TTS i/n |
|
||||||
|
| Dogfooding 导出 | 单篇 metrics JSON(可选调试菜单) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 风险与架构对策
|
||||||
|
|
||||||
|
| 风险 | 架构对策 |
|
||||||
|
|------|----------|
|
||||||
|
| Zotero API 变更 | Adapter 层隔离;pin 版本 |
|
||||||
|
| MinerU 打包复杂 | Phase 0 验证;文档化外部依赖安装 |
|
||||||
|
| refs 幻觉 | 校验器 + degraded beat |
|
||||||
|
| TTS 无 timestamps | HighlightGranularity 降级 |
|
||||||
|
| SQLite 锁 | 写操作单线程队列 |
|
||||||
|
| 备课中断 | 状态机 + 阶段持久化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 开放问题
|
||||||
|
|
||||||
|
1. SQLite 用 `better-sqlite3` 还是 Zotero `Services.storage` — 待插件 POC 验证 WASM/ native 限制
|
||||||
|
2. MinerU 随插件分发 vs 用户自行安装 Python 环境
|
||||||
|
3. 问答 TTS MVP 用流式还是整段合成(影响架构复杂度)
|
||||||
|
4. 伴读库窗口:全库扫描性能(>5000 items 时的索引策略)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 自 PLAN.md 拆分为架构初稿 |
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
# ChatPapers 双模式集成架构设计
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | ChatPapers for Zotero |
|
||||||
|
| 文档名称 | 双模式集成架构(方案 A) |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 目标平台 | Zotero 9(macOS / Windows) |
|
||||||
|
| 关联文档 | [双模式 PRD](./DUAL_MODE_PRD.md)、[开发文档](../development.md)、[BanDu 架构参考](./ARCHITECTURE.md) |
|
||||||
|
|
||||||
|
> **本文档定位**:描述 ChatPapers 插件在保留**文字对话**能力的基础上,扩展**语音伴读**(备课 → 听课 → 语音问答)的架构设计。
|
||||||
|
> 产品统一品牌为 **ChatPapers**;两种模式是同一条「与论文对话」产品线的不同交互形态——文字即时聊 vs 语音系统讲。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概述
|
||||||
|
|
||||||
|
### 1.1 架构演进
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.2(当前) v1.0(目标)
|
||||||
|
───────────────── ─────────────────────────────────
|
||||||
|
ChatPapers 插件 ChatPapers 插件(单一 .xpi)
|
||||||
|
└── 文字对话 pane ├── 文字对话 pane(已有)
|
||||||
|
PDFWorker 纯文本 │ 即时问答 / 总结 / JSON 会话
|
||||||
|
LLM 流式 │
|
||||||
|
sessions/*.json └── 语音伴读 pane(新增)
|
||||||
|
MinerU 段落树
|
||||||
|
Lecture 备课 + TTS + 播放器
|
||||||
|
SQLite + mp3 缓存
|
||||||
|
共享:LLM · PDF 附件 · 笔记 · BibTeX
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 架构风格
|
||||||
|
|
||||||
|
**Local-first 双模式插件架构**:核心计算与缓存均在用户机器完成;仅将段落文本与用户问题发送至用户配置的 LLM / TTS 服务。无自建后端。
|
||||||
|
|
||||||
|
### 1.3 设计原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 单一插件、两种对话形态 | 文字对话与语音伴读并存,共享 LLM 与 Zotero 集成 |
|
||||||
|
| 模块隔离 | 语音模块不侵入文字对话代码路径 |
|
||||||
|
| 备课一次、缓存复用 | Lecture 持久化;attachment ID + 文件哈希为缓存键 |
|
||||||
|
| 增量交付 | 摘要 → beats → 逐段 → TTS 分阶段就绪 |
|
||||||
|
| UI / 领域 / 集成 分离 | 降低 Zotero API 变动影响 |
|
||||||
|
| Grounding 优先 | 生成内容必须可追溯到段落 ID |
|
||||||
|
| 优雅降级 | refs 无效、无 timestamps、无 BBT 均有 fallback |
|
||||||
|
|
||||||
|
### 1.4 系统上下文
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 用户 │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────┐
|
||||||
|
│ Zotero 9(宿主) │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ ChatPapers 插件 │ │
|
||||||
|
│ │ ┌──────────┬─────────┐ │ │
|
||||||
|
│ │ │文字对话 │语音伴读 │ │ │
|
||||||
|
│ │ │ ChatView │LectureUI│ │ │
|
||||||
|
│ │ └────┬─────┴────┬────┘ │ │
|
||||||
|
│ │ │ Shared │ │ │
|
||||||
|
│ │ └─────┬─────┘ │ │
|
||||||
|
│ └────────────┼────────────┘ │
|
||||||
|
└───────────────┼──────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────┼─────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Zotero │ │ LLM API │ │ TTS API │
|
||||||
|
│ PDFWorker │ │ / Ollama │ │ 火山/Azure等 │
|
||||||
|
│ MinerU │ │ (共享) │ │(语音模式) │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ {Profile}/chatpapers/ │
|
||||||
|
│ sessions/*.json ← 文字对话 │
|
||||||
|
│ lecture/lecture.db ← 语音伴读 │
|
||||||
|
│ lecture/audio/ │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 两种对话形态对比
|
||||||
|
|
||||||
|
| 维度 | 文字对话(已有) | 语音伴读(新增) |
|
||||||
|
|------|------------------|------------------|
|
||||||
|
| 用户意图 | 「我现在想问什么」 | 「请 AI 先读完再给我讲」 |
|
||||||
|
| 交互 | 打字 → 文字流式回复 | 听课 → 打字提问 → 文字 + 语音回复 |
|
||||||
|
| PDF 输入 | PDFWorker 整篇纯文本 | MinerU 结构化段落树 |
|
||||||
|
| LLM 用法 | 每轮即时组装 context | 一次性备课 + 按需问答 |
|
||||||
|
| 持久化 | `sessions/{itemKey}.json` | `lecture/lecture.db` + mp3 |
|
||||||
|
| TTS | 无 | 讲解 + 问答回答均支持 |
|
||||||
|
| 笔记 | `ChatPapers:` 前缀 | `ChatPapers 伴读报告:` 前缀 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 整体分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer(表现层) │
|
||||||
|
│ 【文字】chatView.ts · multiChatView.ts · readerPane (Chat sec) │
|
||||||
|
│ 【语音】lecturePane.ts · player/ · libraryWindow.ts(P1) │
|
||||||
|
│ 【共享】prefs.ts · preferences.xhtml │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Application Layer(应用层 / 用例) │
|
||||||
|
│ 【文字】buildChatMessages · buildMultiChatMessages(已有) │
|
||||||
|
│ 【语音】PrepareLectureUseCase · PlayUseCase │
|
||||||
|
│ AskQuestionUseCase · GenerateReportUseCase │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Domain Layer(领域层) │
|
||||||
|
│ 【文字】ChatMessage · ExtractResult(已有) │
|
||||||
|
│ 【语音】PaperCache · Paragraph · Chapter · Lecture · Beat │
|
||||||
|
│ ParagraphExplanation · QARecord · Progress · Highlight │
|
||||||
|
│ LecturePreparationState · PlayerState │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Infrastructure Layer(基础设施层) │
|
||||||
|
│ ┌─ 共享 ─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ llm/client · llm/providers · zotero/notes · zotero/citeExport│ │
|
||||||
|
│ │ pdf/extractor (附件定位) · pdf/selection · utils/* │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 文字专属 ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ pdf/context · pdf/multiContext · storage/sessions │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 语音专属 ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ lecture/infrastructure/pdf (MinerU) · tts/ · storage/sqlite │ │
|
||||||
|
│ │ lecture/infrastructure/rag · audioStorage · promptRegistry │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 现有代码映射
|
||||||
|
|
||||||
|
### 3.1 共享层(已实现,语音模式直接复用)
|
||||||
|
|
||||||
|
| 模块路径 | 职责 | 文字对话 | 语音伴读 |
|
||||||
|
|----------|------|:--------:|:--------:|
|
||||||
|
| `src/hooks.ts` | 生命周期、注册入口 | ✓ | 扩展注册 |
|
||||||
|
| `src/modules/ui/readerPane.ts` | Item pane section 注册 | ✓ | 扩展第二 section |
|
||||||
|
| `src/modules/llm/client.ts` | LLM 流式请求 | ✓ | ✓ 备课 + 问答 |
|
||||||
|
| `src/modules/llm/providers.ts` | Provider 预设 | ✓ | ✓ 只读共享 prefs |
|
||||||
|
| `src/modules/llm/types.ts` | ChatMessage 等类型 | ✓ | 问答层可复用类型 |
|
||||||
|
| `src/modules/pdf/extractor.ts` | PDF 附件定位 | ✓ | ✓ 路径 + 扩展 hash |
|
||||||
|
| `src/modules/pdf/selection.ts` | Reader 选区 | ✓ | ✓ 「解释这句」 |
|
||||||
|
| `src/modules/zotero/notes.ts` | 子笔记创建 | ✓ | ✓ 伴读报告 |
|
||||||
|
| `src/modules/zotero/citeExport.ts` | BibTeX 导出 | ✓ | ✓ 参考文献卡片 |
|
||||||
|
| `src/utils/markdown.ts` | Markdown 渲染 | ✓ | ✓ 报告预览 |
|
||||||
|
| `src/utils/prefs.ts` | Preference 读写 | ✓ | ✓ 扩展 TTS prefs |
|
||||||
|
| `src/utils/abort.ts` | 请求取消 | ✓ | ✓ |
|
||||||
|
|
||||||
|
### 3.2 文字对话专属(已实现,语音模式不得引用)
|
||||||
|
|
||||||
|
| 模块路径 | 职责 |
|
||||||
|
|----------|------|
|
||||||
|
| `src/modules/ui/chatView.ts` | 文字对话 UI |
|
||||||
|
| `src/modules/ui/multiChatView.ts` | 多 PDF 联合对话 |
|
||||||
|
| `src/modules/pdf/context.ts` | 单篇 context 组装 |
|
||||||
|
| `src/modules/pdf/multiContext.ts` | 多篇 context 组装 |
|
||||||
|
| `src/modules/llm/prompts.ts` | 文字对话 system / summary prompt |
|
||||||
|
| `src/modules/llm/history.ts` | 论文材料持久化到消息历史 |
|
||||||
|
| `src/modules/storage/sessions.ts` | JSON 会话文件 |
|
||||||
|
|
||||||
|
### 3.3 语音伴读新建(`src/modules/lecture/`)
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `lecture/ui/lecturePane.ts` | Item pane「语音伴读」section |
|
||||||
|
| `lecture/ui/playerController.ts` | 播放状态机 + `<audio>` |
|
||||||
|
| `lecture/ui/unitList.ts` | beats / 段落列表 |
|
||||||
|
| `lecture/ui/qaPanel.ts` | 锚定提问 + 流式 + 重播 |
|
||||||
|
| `lecture/application/prepareLecture.ts` | 备课流水线编排 |
|
||||||
|
| `lecture/application/playLecture.ts` | 播放 / 跳播 / 断点 |
|
||||||
|
| `lecture/application/askQuestion.ts` | 锚定问答 + RAG |
|
||||||
|
| `lecture/application/generateReport.ts` | 报告组装 + Note 回写 |
|
||||||
|
| `lecture/domain/*` | 领域实体与状态机 |
|
||||||
|
| `lecture/infrastructure/pdf/parser.ts` | MinerU 封装 |
|
||||||
|
| `lecture/infrastructure/pdf/fileHash.ts` | SHA-256 |
|
||||||
|
| `lecture/infrastructure/tts/client.ts` | TTS 多提供商 |
|
||||||
|
| `lecture/infrastructure/tts/providers.ts` | TTS 预设 |
|
||||||
|
| `lecture/infrastructure/storage/repository.ts` | SQLite CRUD |
|
||||||
|
| `lecture/infrastructure/storage/audioStorage.ts` | mp3 路径管理 |
|
||||||
|
| `lecture/infrastructure/rag/index.ts` | FTS5 段落检索 |
|
||||||
|
| `lecture/infrastructure/llm/lecturePrompts.ts` | 备课 / beats / 三层讲解 prompt |
|
||||||
|
| `lecture/infrastructure/events.ts` | EventBus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 依赖规则
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ utils/ │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ llm/ │ │ pdf/ext │ │ zotero/ │
|
||||||
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
┌────┴───────┐ │ ┌────┴─────┐
|
||||||
|
│ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼
|
||||||
|
chatView multiChat selection notes citeExport
|
||||||
|
(文字 UI) (文字 UI) (共享)
|
||||||
|
|
||||||
|
lecture/* ──可引用──► llm/, pdf/extractor, pdf/selection, zotero/, utils/
|
||||||
|
|
||||||
|
chatView / multiChat / context / sessions ──禁止引用──► lecture/*
|
||||||
|
lecture/* ──禁止引用──► chatView, context, sessions, multiContext
|
||||||
|
```
|
||||||
|
|
||||||
|
**运行时隔离**:两个 pane 的 `onAsyncRender` 各自实例化 View,异常在 View 边界捕获,不向上冒泡至 `hooks.ts` 导致整个插件失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 启动与注册流程
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks.ts(目标形态,伪代码)
|
||||||
|
async function onStartup() {
|
||||||
|
await Promise.all([Zotero.initializationPromise, ...]);
|
||||||
|
|
||||||
|
initLocale();
|
||||||
|
registerPrefs(); // 扩展 TTS / 教师风格分组
|
||||||
|
registerChatPane(); // 已有:文字对话 section
|
||||||
|
registerLecturePane(); // 新增:语音伴读 section
|
||||||
|
registerReaderSelectionHook();
|
||||||
|
|
||||||
|
addon.data.initialized = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// readerPane.ts 扩展策略
|
||||||
|
export function registerChatPane() { /* 现有,不动 */ }
|
||||||
|
|
||||||
|
export function registerLecturePane() {
|
||||||
|
Zotero.ItemPaneManager.registerSection({
|
||||||
|
paneID: "chatpapers-lecture",
|
||||||
|
pluginID: config.addonID,
|
||||||
|
header: { l10nID: "chatpapers-item-section-lecture-head", ... },
|
||||||
|
onItemChange: ({ item, setEnabled }) => {
|
||||||
|
setEnabled(Boolean(item && findPdfAttachment(item)));
|
||||||
|
},
|
||||||
|
onAsyncRender: async ({ body, item }) => {
|
||||||
|
const view = new LecturePaneView(doc, body, item);
|
||||||
|
await view.mount();
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 语音伴读模块详细设计
|
||||||
|
|
||||||
|
### 6.1 Zotero 集成适配(复用 + 薄封装)
|
||||||
|
|
||||||
|
语音模块不新建独立 Adapter 文件树,而是在 `lecture/infrastructure/zotero/` 做薄封装,内部调用已有模块:
|
||||||
|
|
||||||
|
| 接口 | 实现 |
|
||||||
|
|------|------|
|
||||||
|
| `getPdfAttachment(item)` | 委托 `findPdfAttachment` |
|
||||||
|
| `computeFileHash(path)` | 新增:`IOUtils.read` + SubtleCrypto / nsICryptoHash |
|
||||||
|
| `openPdfAtPage(itemId, page)` | 新增:Zotero.Reader API |
|
||||||
|
| `createLectureNote(item, markdown)` | 委托 `createChildNote`,标题 `ChatPapers 伴读报告: {title}` |
|
||||||
|
| `exportBibtex(item)` | 委托 `citeExport.formatCiteText` |
|
||||||
|
|
||||||
|
### 6.2 PdfParser(MinerU)
|
||||||
|
|
||||||
|
**与文字对话 PDF 路径分离**:文字对话继续 `PDFWorker.getFullText`;语音伴读走 MinerU。
|
||||||
|
|
||||||
|
**输入**:PDF 本地路径(来自 attachment)
|
||||||
|
**输出**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ParseResult {
|
||||||
|
chapters: Chapter[];
|
||||||
|
paragraphs: Paragraph[];
|
||||||
|
metadata: { pageCount: number; detectedLanguage?: "en" | "zh" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paragraph {
|
||||||
|
id: string; // "p-0017"
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
bulletPoints?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败策略**:
|
||||||
|
|
||||||
|
| 错误类型 | UI 文案 |
|
||||||
|
|----------|---------|
|
||||||
|
| `ScannedPdfError` | 扫描版 PDF,请先 OCR |
|
||||||
|
| `ParseTimeoutError` | 解析超时,请重试 |
|
||||||
|
| `MinerUNotFoundError` | 未检测到 MinerU,见设置页安装说明 |
|
||||||
|
|
||||||
|
### 6.3 LLM 扩展(共享 client,专属 prompt)
|
||||||
|
|
||||||
|
`llm/client.ts` 保持通用 `chatStream`;语音备课在 `lecture/infrastructure/llm/lecturePrompts.ts` 定义模板,通过 client 调用:
|
||||||
|
|
||||||
|
| 方法 | 位置 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `chatStream` | `llm/client.ts`(已有) | 文字对话 + 语音问答 |
|
||||||
|
| `generateSummary` | `lecture/.../lectureLlm.ts` | 全文摘要四字段 |
|
||||||
|
| `generateBeats` | 同上 | 8–15 beats + refs[] |
|
||||||
|
| `generateParagraphExplanation` | 同上 | 三层讲解 |
|
||||||
|
| `generateSentenceExplanation` | 同上 | 逐句讲解 |
|
||||||
|
| `answerWithRag` | `lecture/application/askQuestion.ts` | 锚定问答 |
|
||||||
|
|
||||||
|
**配置**:语音备课 temperature 0.2–0.4;与文字对话共用 `getRuntimeConfig()`,不新增 LLM prefs 键。
|
||||||
|
|
||||||
|
### 6.4 TtsClient
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[];
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TtsProvider {
|
||||||
|
name: string;
|
||||||
|
synthesize(text: string, options: TtsOptions): Promise<TtsResult>;
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**高亮降级链**:`WORD` → `SENTENCE` → `PARAGRAPH` → `NONE`(仅进度条)
|
||||||
|
|
||||||
|
**Prefs 键(建议)**:`extensions.zotero.chatpapers.ttsProvider` / `ttsApiKey` / `ttsVoice`
|
||||||
|
|
||||||
|
### 6.5 RagIndex
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RagIndex {
|
||||||
|
build(paperId: string, paragraphs: Paragraph[]): Promise<void>;
|
||||||
|
search(paperId: string, query: string, topK: number): Paragraph[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一期 SQLite FTS5,不引入 embedding。索引在解析完成后构建,与 `paragraph_fts` 表同步。
|
||||||
|
|
||||||
|
### 6.6 PrepareLectureUseCase
|
||||||
|
|
||||||
|
**职责**:编排备课流水线;发出增量就绪事件;保证同一 paper 互斥(备课锁)。
|
||||||
|
|
||||||
|
**伪流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
start(paperId):
|
||||||
|
if lecture_status != idle && != failed: return // 防重入
|
||||||
|
set status → parsing
|
||||||
|
paragraphs ← PdfParser.parse(path)
|
||||||
|
persist paragraphs + FTS
|
||||||
|
set status → summarizing
|
||||||
|
summary ← Llm.generateSummary(paragraphs)
|
||||||
|
persist lecture.summary
|
||||||
|
set status → beats_generating
|
||||||
|
beats ← Llm.generateBeats(summary, paragraphIndex)
|
||||||
|
beats ← validateRefs(beats, paragraphIds)
|
||||||
|
persist beats
|
||||||
|
emit lecture:beats_ready // UI 可开整体讲解
|
||||||
|
set status → paragraphs_generating
|
||||||
|
for batch in paragraphs.chunk(5):
|
||||||
|
explanations ← Llm.generateParagraphExplanation(batch)
|
||||||
|
persist + emit lecture:paragraph_ready
|
||||||
|
set status → tts_generating
|
||||||
|
for unit in beats + explanations:
|
||||||
|
audio ← Tts.synthesize(unit.script)
|
||||||
|
persist audio_path + emit tts:unit_ready
|
||||||
|
set status → ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 PlayUseCase + PlayerController
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerState {
|
||||||
|
mode: "overview" | "paragraph";
|
||||||
|
currentUnitId: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playbackRate: 1 | 1.5 | 2;
|
||||||
|
highlightCursorMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳播**:`currentUnitId` 变更 → 加载 mp3 → `audio.currentTime = 0` → 更新 `progress` 表。
|
||||||
|
|
||||||
|
**断点续播**:pane 打开时读 `progress.last_unit_id` + `last_position_ms`。
|
||||||
|
|
||||||
|
### 6.8 AskQuestionUseCase
|
||||||
|
|
||||||
|
与文字对话的差异:
|
||||||
|
|
||||||
|
| 项 | 文字对话 | 语音伴读问答 |
|
||||||
|
|----|----------|--------------|
|
||||||
|
| 上下文 | 整篇 PDF 文本(可能截断) | 锚点 + 摘要 + RAG topK=5 |
|
||||||
|
| 存储 | sessions JSON | qa_record 表 |
|
||||||
|
| 输出 | 仅文字流 | 文字流 + TTS mp3 |
|
||||||
|
| 锚点 | 选区字符串 | beat / paragraph / sentence / global |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AskContext {
|
||||||
|
question: string;
|
||||||
|
anchor: {
|
||||||
|
type: "beat" | "paragraph" | "sentence" | "global";
|
||||||
|
id?: string;
|
||||||
|
text?: string;
|
||||||
|
explanation?: string;
|
||||||
|
};
|
||||||
|
summary: SummaryJson;
|
||||||
|
ragParagraphs: Paragraph[];
|
||||||
|
paperTitle: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.9 GenerateReportUseCase
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportBuilder.build(paperId):
|
||||||
|
summary ← lecture.summary_json
|
||||||
|
progress ← progress.listened_units_json
|
||||||
|
qas ← qa_record ORDER BY created_at
|
||||||
|
highlights ← highlight JOIN paragraph
|
||||||
|
bibtex ← citeExport
|
||||||
|
|
||||||
|
MarkdownRenderer → createLectureNote(parentItem, markdown)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note 策略**:同名 `ChatPapers 伴读报告: {title}` 存在则更新,避免笔记爆炸。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据模型
|
||||||
|
|
||||||
|
### 7.1 存储目录
|
||||||
|
|
||||||
|
```
|
||||||
|
{ZoteroProfile}/chatpapers/
|
||||||
|
├── sessions/ # 文字对话(已有)
|
||||||
|
│ └── {itemKey}-{attachmentKey}.json
|
||||||
|
└── lecture/ # 语音伴读(新增)
|
||||||
|
├── lecture.db # SQLite
|
||||||
|
└── audio/
|
||||||
|
└── {cacheKey}/
|
||||||
|
├── beat-{id}.mp3
|
||||||
|
├── para-{id}.mp3
|
||||||
|
└── qa-{id}.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 缓存键
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
|
||||||
|
失效条件:
|
||||||
|
• fileHash 变化
|
||||||
|
• 用户强制重新备课
|
||||||
|
• lecture prompt_version major 升级(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PaperCache 1──* Paragraph
|
||||||
|
PaperCache 1──1 Lecture
|
||||||
|
Lecture 1──* Beat
|
||||||
|
Lecture 1──* ParagraphExplanation
|
||||||
|
PaperCache 1──* QARecord
|
||||||
|
PaperCache 1──1 Progress
|
||||||
|
PaperCache 1──* Highlight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 SQLite 表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 论文缓存根表
|
||||||
|
CREATE TABLE paper_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT,
|
||||||
|
parse_status TEXT NOT NULL,
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
chapter_id TEXT REFERENCES chapter(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
summary_json TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY REFERENCES paragraph(id),
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE progress (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
last_mode TEXT,
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
anchor_type TEXT NOT NULL,
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE highlight (
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
paragraph_id TEXT NOT NULL REFERENCES paragraph(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE paragraph_fts USING fts5(
|
||||||
|
paragraph_id,
|
||||||
|
paper_id,
|
||||||
|
text,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 Beat refs 校验
|
||||||
|
|
||||||
|
```
|
||||||
|
输入: beats[], validParagraphIds (Set)
|
||||||
|
对每个 beat:
|
||||||
|
beat.refs = beat.refs.filter(id => validParagraphIds.has(id))
|
||||||
|
if beat.refs.isEmpty:
|
||||||
|
beat.degraded = true
|
||||||
|
输出: beats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 摘要 JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"problem": "…",
|
||||||
|
"method": "…",
|
||||||
|
"result": "…",
|
||||||
|
"limitation": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 核心流水线
|
||||||
|
|
||||||
|
### 8.1 语音伴读端到端
|
||||||
|
|
||||||
|
```
|
||||||
|
PDF attachment path
|
||||||
|
→ findPdfAttachment + computeFileHash
|
||||||
|
→ [MinerU PdfParser] → paragraphs[] → SQLite + FTS
|
||||||
|
→ [generateSummary] → lecture.summary
|
||||||
|
→ [generateBeats] → refs 校验 → SQLite
|
||||||
|
→ emit lecture:beats_ready ──► 整体讲解可播(文本)
|
||||||
|
→ [generateParagraphExplanation] × N → SQLite
|
||||||
|
→ emit lecture:paragraph_ready
|
||||||
|
→ [Tts.synthesize] × units → mp3 → SQLite
|
||||||
|
→ emit tts:unit_ready ──► 播放器加载音频
|
||||||
|
→ 用户提问 → [AskQuestion + RAG] → 文字流 + TTS
|
||||||
|
→ [GenerateReport] → Zotero Note
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 增量备课时序
|
||||||
|
|
||||||
|
```
|
||||||
|
时间 ──────────────────────────────────────────────►
|
||||||
|
|
||||||
|
解析 ████████
|
||||||
|
摘要 ███
|
||||||
|
beats ██████ ← beats_ready(~1-2min,可先听文本/TTS排队)
|
||||||
|
逐段讲解 ████████████████████
|
||||||
|
TTS(beats) ████████
|
||||||
|
TTS(段落) █████████████████
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 文字对话路径(不变)
|
||||||
|
|
||||||
|
```
|
||||||
|
Item → findPdfAttachment → PDFWorker.getFullText
|
||||||
|
→ buildChatMessages → chatStream → ChatView 渲染
|
||||||
|
→ saveSession (JSON)
|
||||||
|
→ createChildNote(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 状态机
|
||||||
|
|
||||||
|
### 9.1 备课状态(lecture_status)
|
||||||
|
|
||||||
|
```
|
||||||
|
idle → parsing → parsed → summarizing → beats_generating
|
||||||
|
→ paragraphs_generating (batch loop) → tts_generating → ready
|
||||||
|
任何阶段 ──error──► failed(可从 failed 或任意已完成阶段 resume)
|
||||||
|
```
|
||||||
|
|
||||||
|
**可恢复**:每阶段结束写库;重启后读 `lecture_status`,跳过已完成步骤。
|
||||||
|
|
||||||
|
### 9.2 播放器状态
|
||||||
|
|
||||||
|
见 §6.7。pane `onDestroy` 时持久化 progress,释放 `<audio>` 引用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 内部事件(EventBus)
|
||||||
|
|
||||||
|
| 事件 | 载荷 | 订阅者 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `parse:progress` | `{ paperId, percent, message }` | LecturePane |
|
||||||
|
| `lecture:beats_ready` | `{ paperId, beatCount }` | LecturePane, Player |
|
||||||
|
| `lecture:paragraph_ready` | `{ paperId, paragraphId }` | LecturePane |
|
||||||
|
| `tts:unit_ready` | `{ paperId, unitId, audioPath }` | Player |
|
||||||
|
| `lecture:failed` | `{ paperId, error }` | LecturePane |
|
||||||
|
| `qa:chunk` | `{ textDelta }` | QAPanel |
|
||||||
|
| `qa:complete` | `{ qaRecordId, audioPath? }` | QAPanel |
|
||||||
|
|
||||||
|
EventBus 实例按 `paperId` 或全局单例均可;MVP 用模块级 `EventTarget` 即可。
|
||||||
|
|
||||||
|
**长时间任务**:
|
||||||
|
- MinerU:子进程(不阻塞 UI)
|
||||||
|
- LLM / TTS:`async fetch` + AbortController
|
||||||
|
- SQLite 写:单线程队列,避免并发锁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 设置与 Preference 设计
|
||||||
|
|
||||||
|
### 11.1 分组结构(preferences.xhtml)
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatPapers 设置
|
||||||
|
├── 通用 · LLM ← 两模式共享
|
||||||
|
│ Provider / Base URL / API Key / Model / Temperature
|
||||||
|
├── 文字对话 ← 已有
|
||||||
|
│ System Prompt / Summary Prompt / Answer Language
|
||||||
|
└── 语音伴读 ← 新增
|
||||||
|
TTS Provider / API Key / Voice
|
||||||
|
教师风格(简洁 / 详细)
|
||||||
|
MinerU 路径(若需)
|
||||||
|
数据流向说明(静态文案)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Prefs 键命名
|
||||||
|
|
||||||
|
沿用 `extensions.zotero.chatpapers.*` 前缀,不新增 addon ID:
|
||||||
|
|
||||||
|
| 键 | 模式 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `provider` | 共享 | 已有 |
|
||||||
|
| `apiKey` | 共享 | 已有 |
|
||||||
|
| `systemPrompt` | 文字 | 已有 |
|
||||||
|
| `ttsProvider` | 语音 | 新增 |
|
||||||
|
| `ttsApiKey` | 语音 | 新增 |
|
||||||
|
| `ttsVoice` | 语音 | 新增 |
|
||||||
|
| `lectureTeacherStyle` | 语音 | 新增 |
|
||||||
|
| `mineruPath` | 语音 | 新增,可选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 目标工程结构
|
||||||
|
|
||||||
|
在 [development.md](../development.md) 现有结构基础上扩展:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── hooks.ts
|
||||||
|
├── modules/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── readerPane.ts # 注册 Chat + Lecture 两个 section
|
||||||
|
│ │ ├── chatView.ts # 文字对话(不动)
|
||||||
|
│ │ ├── multiChatView.ts # 文字多 PDF(不动)
|
||||||
|
│ │ └── prefs.ts
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── extractor.ts # 共享:附件定位
|
||||||
|
│ │ ├── context.ts # 文字专属
|
||||||
|
│ │ ├── multiContext.ts # 文字专属
|
||||||
|
│ │ └── selection.ts # 共享
|
||||||
|
│ ├── llm/ # 共享 client + providers
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ └── sessions.ts # 文字专属
|
||||||
|
│ ├── zotero/ # 共享
|
||||||
|
│ └── lecture/ # ★ 语音伴读(新建)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── lecturePane.ts
|
||||||
|
│ │ ├── playerController.ts
|
||||||
|
│ │ ├── unitList.ts
|
||||||
|
│ │ └── qaPanel.ts
|
||||||
|
│ ├── application/
|
||||||
|
│ │ ├── prepareLecture.ts
|
||||||
|
│ │ ├── playLecture.ts
|
||||||
|
│ │ ├── askQuestion.ts
|
||||||
|
│ │ └── generateReport.ts
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── types.ts
|
||||||
|
│ │ ├── lectureState.ts
|
||||||
|
│ │ └── playerState.ts
|
||||||
|
│ └── infrastructure/
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── parser.ts
|
||||||
|
│ │ └── fileHash.ts
|
||||||
|
│ ├── tts/
|
||||||
|
│ │ ├── client.ts
|
||||||
|
│ │ └── providers.ts
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ ├── repository.ts
|
||||||
|
│ │ ├── schema.ts
|
||||||
|
│ │ └── audioStorage.ts
|
||||||
|
│ ├── rag/
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── llm/
|
||||||
|
│ │ ├── lectureLlm.ts
|
||||||
|
│ │ └── lecturePrompts.ts
|
||||||
|
│ ├── zotero/
|
||||||
|
│ │ └── adapter.ts
|
||||||
|
│ └── events.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**样式**:`addon/content/chatpapers.css` 增加 `.chatpapers-lecture-*` 前缀类,与文字对话样式并列。
|
||||||
|
|
||||||
|
**i18n**:`addon/locale/*/chatpapers.ftl` 增加 `item-section-lecture-*` 等键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 外部集成
|
||||||
|
|
||||||
|
### 13.1 LLM(共享)
|
||||||
|
|
||||||
|
| 提供商 | 协议 | 文字对话 | 语音伴读 |
|
||||||
|
|--------|------|:--------:|:--------:|
|
||||||
|
| OpenAI 兼容 | HTTPS SSE | ✓ | ✓ 备课 + 问答 |
|
||||||
|
| Ollama | localhost | ✓ | ✓ |
|
||||||
|
|
||||||
|
### 13.2 TTS(语音专属)
|
||||||
|
|
||||||
|
| 提供商 | 选型维度 |
|
||||||
|
|--------|----------|
|
||||||
|
| 火山 / Azure / MiniMax / ElevenLabs | 中文自然度、价格、word timestamps |
|
||||||
|
|
||||||
|
### 13.3 MinerU(语音专属)
|
||||||
|
|
||||||
|
- 集成:子进程 CLI,stdout JSON
|
||||||
|
- 分发:Phase 0 决定「随插件文档安装」vs「用户自装 Python 环境」
|
||||||
|
|
||||||
|
### 13.4 Better BibTeX(共享,可选)
|
||||||
|
|
||||||
|
语音报告参考文献卡片复用 `citeExport`;逻辑已在 Chat 多 PDF 模式验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 安全与隐私
|
||||||
|
|
||||||
|
| 项 | 文字对话 | 语音伴读 |
|
||||||
|
|----|----------|----------|
|
||||||
|
| PDF 文件 | 不上传 | 不上传 |
|
||||||
|
| 发往 LLM | 正文 text(可能截断) | 段落 text + 摘要 + 问题 |
|
||||||
|
| 发往 TTS | — | 讲解/回答中文文本 |
|
||||||
|
| API Key | Zotero prefs | LLM 共享;TTS 独立 pref |
|
||||||
|
| 日志 | 不记录 Key | 不记录 Key;段落正文可截断 |
|
||||||
|
|
||||||
|
设置页「数据流向」分组须同时覆盖两种模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Phase 0 技术验证(Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | 第二 Item pane 与 Chat 并存 | 切换 tab 互不影响 | 是 |
|
||||||
|
| P0-2 | MinerU 解析 3 篇论文 | 段落可用率 ≥ 80% | 是 |
|
||||||
|
| P0-3 | Zotero 9 插件内 SQLite CRUD | lecture.db 读写正常 | 是 |
|
||||||
|
| P0-4 | `<audio>` 10 min+ 播放 | 无泄漏 / 崩溃 | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 | 自然度可接受 | 是 |
|
||||||
|
| P0-6 | TTS timestamps | 有则记录;无则句子级降级 | 否 |
|
||||||
|
| P0-7 | Chat 模式回归 | 对话 / 总结 / 存笔记不受影响 | 是 |
|
||||||
|
|
||||||
|
P0-1、P0-7 验证**双模式隔离**;P0-2 ~ P0-6 验证**语音链路**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 性能与扩展
|
||||||
|
|
||||||
|
### 16.1 长论文策略
|
||||||
|
|
||||||
|
| 页数 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| ≤ 30 | 全量逐段备课 |
|
||||||
|
| 30–80 | 逐段 lazy:按章节或用户触发 |
|
||||||
|
| > 80 | 默认仅整体讲解;逐段需用户确认 |
|
||||||
|
|
||||||
|
### 16.2 并发控制
|
||||||
|
|
||||||
|
- 备课 LLM:beats 小批量并行 ≤ 3
|
||||||
|
- TTS:队列化,beats 优先
|
||||||
|
- 同一 paper 备课互斥锁
|
||||||
|
- SQLite 写串行队列
|
||||||
|
|
||||||
|
### 16.3 伴读库窗口(P1)
|
||||||
|
|
||||||
|
`libraryWindow.ts` 扫描 `paper_cache` 表 + Zotero Items;>5000 items 时仅索引「有过备课记录」的 attachment。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 可观测性
|
||||||
|
|
||||||
|
| 类型 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 结构化日志 | `mode: "lecture"`, paperId, stage, durationMs, errorCode |
|
||||||
|
| 用户可见进度 | 解析 %、beats n/total、TTS n/total |
|
||||||
|
| 调试导出 | 单篇 metrics JSON(开发者菜单,可选) |
|
||||||
|
|
||||||
|
日志前缀建议 `[ChatPapers:Lecture]`,与 `[ChatPapers:Chat]` 区分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 对策 |
|
||||||
|
|------|------|
|
||||||
|
| 语音模块影响文字对话稳定性 | 依赖隔离 + pane 级 try/catch + P0-7 回归 |
|
||||||
|
| MinerU 打包复杂 | Phase 0 验证;文档化安装 |
|
||||||
|
| refs 幻觉 | 校验器 + degraded beat |
|
||||||
|
| TTS 无 timestamps | 高亮降级链 |
|
||||||
|
| SQLite 在插件环境不可用 | Phase 0 备选 sql.js / JSON 分文件 |
|
||||||
|
| 双 pane 内存占用 | onDestroy 释放 audio、取消进行中的 fetch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 开放问题
|
||||||
|
|
||||||
|
| # | 问题 | 影响 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | SQLite:better-sqlite3 vs sql.js vs 纯 JSON | 存储层选型 |
|
||||||
|
| 2 | MinerU 分发方式 | 安装体验 |
|
||||||
|
| 3 | 问答 TTS:流式 vs 整段合成 | AskQuestion 复杂度 |
|
||||||
|
| 4 | 文字对话是否未来支持 TTS 朗读回复 | v1.1+ 可统一 TtsClient |
|
||||||
|
| 5 | Chat 会话 → 语音问答上下文导入 | v1.1 产品决策 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/requirements.md ChatPapers 文字对话需求(不变)
|
||||||
|
docs/development.md 现有工程与工具链(不变)
|
||||||
|
docs/newidea/
|
||||||
|
├── PLAN.md / PRD.md BanDu 原始策划(参考)
|
||||||
|
├── ARCHITECTURE.md 独立插件架构(参考)
|
||||||
|
├── DUAL_MODE_PRD.md ★ 双模式集成需求
|
||||||
|
└── DUAL_MODE_ARCHITECTURE.md ★ 本文:双模式集成架构
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施顺序**:需求看 DUAL_MODE_PRD.md → 架构看本文 → 领域细节参考 ARCHITECTURE.md §4–§6。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 初稿:ChatPapers 双模式集成架构;语音模块命名 `lecture/` |
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# ChatPapers 双模式集成需求规格说明书
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 文档名称 | 双模式集成 PRD(方案 A) |
|
||||||
|
| 插件名称 | ChatPapers for Zotero |
|
||||||
|
| 新增模式 | BanDu(伴读) |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 目标平台 | Zotero 9(macOS / Windows) |
|
||||||
|
| 关联文档 | [ChatPapers 需求](../requirements.md)、[BanDu 策划](./PLAN.md)、[BanDu PRD](./PRD.md)、[BanDu 架构](./ARCHITECTURE.md) |
|
||||||
|
|
||||||
|
> **本文档定位**:在**不拆分、不 fork** 的前提下,将 BanDu(伴读)作为 ChatPapers 插件的**第二种工作模式**纳入同一产品。
|
||||||
|
> 原有 ChatPapers 对话模式的需求以 [requirements.md](../requirements.md) 为准,本文仅描述**集成策略、共享边界、伴读模式增量需求**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品概述
|
||||||
|
|
||||||
|
### 1.1 一句话定位
|
||||||
|
|
||||||
|
**ChatPapers** = Zotero 内的 AI 论文助手:**对话模式**用于即时问答与总结,**伴读模式**用于 AI 老师式音频授课与知识沉淀——同一插件、同一论文库、两种互补工作流。
|
||||||
|
|
||||||
|
### 1.2 双模式心智模型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ChatPapers for Zotero │
|
||||||
|
│ (一个插件 · 一套 Zotero 集成) │
|
||||||
|
└───────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┴─────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ 模式 A:对话 Chat │ │ 模式 B:伴读 BanDu │
|
||||||
|
│ 「我现在想问什么」 │ │ 「请老师先备课再讲」 │
|
||||||
|
├──────────────────────┤ ├──────────────────────┤
|
||||||
|
│ 即时问答 / 一键总结 │ │ 备课 → 听课 → 报告 │
|
||||||
|
│ 文字流式回复 │ │ 整体讲解 / 逐段精读 │
|
||||||
|
│ 选区提问 │ │ TTS 音频 + 播放器 │
|
||||||
|
│ 会话 JSON 持久化 │ │ Lecture SQLite 缓存 │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
└─────────────────────┬─────────────────────┘
|
||||||
|
▼
|
||||||
|
共享:PDF 附件 · LLM 配置 · 子笔记 · BibTeX
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 为什么采用双模式(方案 A)
|
||||||
|
|
||||||
|
| 考量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 用户价值互补 | Chat 适合「带着问题读」;伴读适合「先听一遍再精读」 |
|
||||||
|
| 工程复用 | LLM 客户端、PDF 附件解析、笔记回写、引用导出、设置页已在 Chat 模式实现 |
|
||||||
|
| 安装成本 | 用户只装一个插件,不重复配置 Provider / API Key |
|
||||||
|
| 数据闭环 | 伴读报告与 Chat 笔记均写入同一 Zotero Item,写作时可统一检索 |
|
||||||
|
| 品牌演进 | 短期保留 ChatPapers 主品牌;伴读作为子品牌「BanDu」在 UI 内露出 |
|
||||||
|
|
||||||
|
### 1.4 产品目标(伴读模式增量)
|
||||||
|
|
||||||
|
| 目标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| G-B1 降低读论文门槛 | 10–20 分钟整体讲解把握论文核心 |
|
||||||
|
| G-B2 支持深度精读 | 逐段三层讲解 + 按需逐句解释 |
|
||||||
|
| G-B3 闭环沉淀知识 | 伴读报告、问答、重点标记写入 Zotero 子笔记 |
|
||||||
|
| G-B4 零迁移成本 | 论文库沿用 Zotero,PDF 不出本地 |
|
||||||
|
|
||||||
|
### 1.5 成功指标(伴读 MVP 验证期)
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 整体讲解完成率 | ≥ 60% 启动后听完 |
|
||||||
|
| 单篇备课可听时间 | 摘要 + beats 生成后 ≤ 2 min 可开听 |
|
||||||
|
| 问答首字延迟 | ≤ 3 s(文字) |
|
||||||
|
| 解析可用率 | 典型 8 页 NLP 论文 ≥ 90% 段落无需手动修正 |
|
||||||
|
| 模式切换无摩擦 | 同一 Item 可在 Chat / 伴读 pane 间切换,互不丢状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 范围定义
|
||||||
|
|
||||||
|
### 2.1 一期包含(伴读模式 MVP)
|
||||||
|
|
||||||
|
- 在现有 ChatPapers 插件内新增 Item pane **「伴读」** 标签页(与现有 **「ChatPapers」** 标签页并存)
|
||||||
|
- 读取 Zotero PDF 附件 → 本地结构化解析 → 段落树
|
||||||
|
- 备课流水线:全文摘要 + 整体讲解 beats + 逐段三层讲解;增量备课
|
||||||
|
- TTS 合成 + 本地 mp3 缓存
|
||||||
|
- 播放器:整体讲解 / 逐段精读、跳播、语速、文字面板高亮、PDF 页跳转
|
||||||
|
- 文字提问(锚定 beat / 段落 / 句子 / 全局)→ 文字 + 语音回答
|
||||||
|
- 伴读报告 + 参考文献卡片 → Zotero 子笔记
|
||||||
|
- 伴读专属设置项(TTS、教师风格);LLM 设置与 Chat 模式**共享**
|
||||||
|
- 断点续播、本地 Lecture 缓存
|
||||||
|
|
||||||
|
### 2.2 一期明确不包含
|
||||||
|
|
||||||
|
| 功能 | 计划版本 | 备注 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 语音输入(ASR)、barge-in | v1.1 | 两模式均不涉及 |
|
||||||
|
| 章节边界软提示、难点句清单 | v1.1 | 伴读专属 |
|
||||||
|
| PDF viewer 内段落级高亮 | v1.1 | 伴读专属 |
|
||||||
|
| 检验模式(老师考读者) | v2 | 伴读专属 |
|
||||||
|
| Chat 模式重构或合并 UI | — | Chat 保持独立 pane |
|
||||||
|
| 独立 BanDu 插件安装包 | — | 不 fork |
|
||||||
|
|
||||||
|
### 2.3 共享能力边界(不重复建设)
|
||||||
|
|
||||||
|
以下能力由 ChatPapers 基座提供,伴读模式**直接复用**,不在伴读 MVP 中重写:
|
||||||
|
|
||||||
|
| 共享模块 | 现有实现 | 伴读用法 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Item pane 注册机制 | `readerPane.ts` | 注册第二个 section |
|
||||||
|
| PDF 附件定位 | `extractor.findPdfAttachment` | 获取路径;伴读另需 file hash |
|
||||||
|
| LLM 流式客户端 | `llm/client.ts` | 备课 + 问答 |
|
||||||
|
| Provider 预设 | `llm/providers.ts` | 共享同一套 LLM 配置 |
|
||||||
|
| 子笔记创建 | `zotero/notes.ts` | 伴读报告写入 |
|
||||||
|
| BibTeX / 引用导出 | `zotero/citeExport.ts` | 参考文献卡片 |
|
||||||
|
| PDF 选区读取 | `pdf/selection.ts` | 「解释这句」入口 |
|
||||||
|
| 偏好设置框架 | `ui/prefs.ts` | 扩展 TTS / 教师风格分组 |
|
||||||
|
| Markdown 渲染 | `utils/markdown.ts` | 报告预览(可选) |
|
||||||
|
|
||||||
|
### 2.4 模式专属能力(伴读新建)
|
||||||
|
|
||||||
|
| 模块 | 说明 | 代码归属(建议) |
|
||||||
|
|------|------|------------------|
|
||||||
|
| 结构化 PDF 解析 | MinerU → 段落树 | `src/modules/bandu/infrastructure/pdf/` |
|
||||||
|
| 备课流水线 | 摘要 / beats / 三层讲解 | `src/modules/bandu/application/` |
|
||||||
|
| TTS 客户端 | mp3 + timestamps | `src/modules/bandu/infrastructure/tts/` |
|
||||||
|
| SQLite 存储 | Lecture / 进度 / Q&A | `src/modules/bandu/infrastructure/storage/` |
|
||||||
|
| RAG 检索 | FTS5 段落检索 | `src/modules/bandu/infrastructure/rag/` |
|
||||||
|
| 播放器 UI | `<audio>` + 状态机 | `src/modules/bandu/ui/` |
|
||||||
|
| 伴读 pane | BanduPane | `src/modules/bandu/ui/banduPane.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 用户故事
|
||||||
|
|
||||||
|
### 3.1 跨模式用户故事
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… | 优先级 |
|
||||||
|
|----|-------|---------|-------|--------|
|
||||||
|
| US-X01 | 研究者 | 在同一篇论文上切换「对话」与「伴读」 | 先听课再针对性 Chat,或 Chat 后再系统听课 | P1 |
|
||||||
|
| US-X02 | 研究者 | 只配置一次 LLM API Key | 两种模式共用,降低设置成本 | P0 |
|
||||||
|
| US-X03 | 研究者 | Chat 笔记与伴读报告都挂在同一 Item 下 | 写论文时在 Zotero 内统一搜索 | P0 |
|
||||||
|
|
||||||
|
### 3.2 伴读模式用户故事(P0)
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… |
|
||||||
|
|----|-------|---------|-------|
|
||||||
|
| US-B01 | 研究者 | 选中论文后一键「开始备课」 | AI 先读完整篇再讲解 |
|
||||||
|
| US-B02 | 研究者 | 选择「整体讲解」收听 | 20 分钟内把握核心 |
|
||||||
|
| US-B03 | 研究者 | 选择「逐段精读」按章节听 | 深入理解方法与实验 |
|
||||||
|
| US-B04 | 研究者 | 播放中随时文字提问 | 不打断思路又能澄清疑问 |
|
||||||
|
| US-B05 | 研究者 | 选中难句点「解释这句」 | 快速理解长难句 |
|
||||||
|
| US-B06 | 研究者 | 听完后生成伴读报告到 Zotero | 写论文时搜索调出素材 |
|
||||||
|
| US-B07 | 研究者 | 二次打开同一篇秒进、离线重听 | 不重复花 API 费用 |
|
||||||
|
| US-B08 | 研究者 | 标记重点段落 | 报告汇总关心部分 |
|
||||||
|
| US-B09 | 研究者 | 配置 TTS API Key | 控制成本与音质 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 界面与交互需求
|
||||||
|
|
||||||
|
### 4.1 Item Pane 双标签布局
|
||||||
|
|
||||||
|
```
|
||||||
|
Zotero Item Pane(同一 PDF 条目)
|
||||||
|
├── [ChatPapers] ← 现有:对话 / 总结 / 存笔记
|
||||||
|
└── [伴读 BanDu] ← 新增:备课 / 播放 / 报告
|
||||||
|
```
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 | 验收标准 |
|
||||||
|
|----|------|--------|----------|
|
||||||
|
| UI-001 | 两个 pane section 独立注册、独立生命周期 | P0 | 切换 tab 不互相 destroy 对方状态 |
|
||||||
|
| UI-002 | 伴读 pane 在无 PDF 时禁用并提示 | P0 | 与 Chat pane 行为一致 |
|
||||||
|
| UI-003 | 伴读 pane 头部展示论文标题 + 备课状态 | P0 | 见 §4.3 状态文案 |
|
||||||
|
| UI-004 | 设置页分「通用 LLM」「ChatPapers」「伴读」三个分组 | P0 | LLM 配置只填一次 |
|
||||||
|
| UI-005 | 伴读库窗口(全库状态列表) | P1 | MVP 可简版或后置 |
|
||||||
|
|
||||||
|
### 4.2 伴读页布局(BanDu Pane)
|
||||||
|
|
||||||
|
布局沿用 [PLAN.md §3.6](./PLAN.md) wireframe,关键区域:
|
||||||
|
|
||||||
|
- 模式切换:整体讲解 | 逐段精读
|
||||||
|
- 播放控制:▶ ⏸ ⏮ ⏭ 1x / 1.5x / 2x
|
||||||
|
- 左侧:单位列表(beats 或段落),含已听 / 未听 / ★ 标记
|
||||||
|
- 右侧:当前讲解文本 + 原文对照 + 提问区
|
||||||
|
- 底部/侧栏:「生成报告」「看原文(跳 PDF 页)」
|
||||||
|
|
||||||
|
### 4.3 状态展示
|
||||||
|
|
||||||
|
| 状态 | 用户可见文案 |
|
||||||
|
|------|--------------|
|
||||||
|
| 未备课 | 「点击开始备课」 |
|
||||||
|
| 解析中 | 「正在读取论文… {percent}%」 |
|
||||||
|
| 备课中(可部分播放) | 「整体讲解已就绪,逐段讲解生成中 {n}/{total}」 |
|
||||||
|
| 已备课 | 「老师已备课 ✓」 |
|
||||||
|
| 播放中 | 当前单位高亮 + 进度条 |
|
||||||
|
| 离线可听 | 「已缓存,离线可播放」 |
|
||||||
|
|
||||||
|
### 4.4 模式间交互原则
|
||||||
|
|
||||||
|
- **不强制串联**:用户可只用 Chat、只用伴读,或组合使用
|
||||||
|
- **不自动同步上下文**:Chat 会话历史不自动注入伴读问答(v1.1 可评估「从 Chat 导入问题」)
|
||||||
|
- **笔记类型区分**:Chat 存笔记标题前缀 `ChatPapers:`;伴读报告 `BanDu 伴读报告:`,避免覆盖
|
||||||
|
- **读者发起,系统不打扰**:伴读播完不弹「有疑问吗」(与 BanDu 策划一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 功能需求
|
||||||
|
|
||||||
|
### 5.1 插件集成(双模式)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| DM-001 | 伴读作为 ChatPapers 插件内独立模块加载,不新增 addon ID | P0 | 单一 `.xpi`,manifest 不变 |
|
||||||
|
| DM-002 | `hooks.ts` 启动时同时注册 Chat pane 与 Bandu pane | P0 | 两 pane 均可用 |
|
||||||
|
| DM-003 | 模块代码物理隔离:`src/modules/bandu/` 与 `src/modules/ui/chatView.ts` 分离 | P0 | 无循环依赖 |
|
||||||
|
| DM-004 | 伴读不得修改 Chat 模式现有行为与 API | P0 | Chat 回归测试通过 |
|
||||||
|
|
||||||
|
### 5.2 PDF 解析与段落树(伴读专属)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B010 | 本地解析 PDF,输出章节 / 段落 / 要点三层结构 | P0 | 段落含唯一 ID、原文、页码、章节路径 |
|
||||||
|
| FR-B011 | 支持双栏、公式、表格的常见学术论文版式 | P0 | Dogfooding 3 类典型论文可用 |
|
||||||
|
| FR-B012 | 解析结果持久化至 SQLite,key = attachment ID + 文件哈希 | P0 | PDF 未变时跳过重复解析 |
|
||||||
|
| FR-B013 | 解析进度与错误对用户可见 | P0 | 扫描版等给出可行动提示 |
|
||||||
|
| FR-B014 | 文字面板展示解析原文供核对 | P1 | 用户可发现 bad case |
|
||||||
|
|
||||||
|
> **与 Chat 模式关系**:Chat 继续使用 `PDFWorker` 纯文本提取;伴读使用 MinerU 结构化解析。两路径并存,互不替换。
|
||||||
|
|
||||||
|
### 5.3 备课(Lecture 生成)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B020 | 生成全文摘要:问题 / 方法 / 结果 / 局限 | P0 | 四字段非空、中文 |
|
||||||
|
| FR-B021 | 生成 8–15 个整体讲解 beat,按教学逻辑排序 | P0 | 每 beat 含讲解文本、类型、refs[] |
|
||||||
|
| FR-B022 | beat 的 refs[] 生成后校验,非法引用剔除 | P0 | 无效 refs 的 beat 降级 |
|
||||||
|
| FR-B023 | 每个 beat 含三要素:为什么重要 / 例子或数字 / 易混淆点 | P0 | Prompt 约束 |
|
||||||
|
| FR-B024 | 每个段落生成三层讲解 | P0 | 讲了什么 / 怎么理解 / 全文定位 |
|
||||||
|
| FR-B025 | 增量备课:摘要 + beats 完成后即可播放整体讲解 | P0 | 无需等逐段全部完成 |
|
||||||
|
| FR-B026 | 逐段讲解后台继续生成,UI 显示进度 | P0 | 新段落就绪后可播放 |
|
||||||
|
| FR-B027 | Lecture 本地缓存,二次打开不重复生成 | P0 | 除非哈希变化或强制重备 |
|
||||||
|
|
||||||
|
### 5.4 TTS 与音频
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B030 | 讲解文本按 beat / 段落分片合成 mp3 并本地缓存 | P0 | 路径与 Lecture 单元绑定 |
|
||||||
|
| FR-B031 | 英文论文音频为中文讲解 | P0 | 自动检测论文语言 |
|
||||||
|
| FR-B032 | 保存 word timestamps(若 TTS 提供) | P0 | 用于文字高亮 |
|
||||||
|
| FR-B033 | 无 timestamps 时降级句子级 / 段落级高亮 | P0 | UI 不崩溃 |
|
||||||
|
| FR-B034 | 问答回答支持 TTS 播放 | P0 | 每轮 Q&A 可重播 |
|
||||||
|
| FR-B035 | TTS 配置在设置页「伴读」分组,与 LLM 配置分离 | P0 | Chat 模式不依赖 TTS |
|
||||||
|
|
||||||
|
### 5.5 播放器
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B040 | 模式切换:整体讲解 / 逐段精读 | P0 | 列表与播放单位正确 |
|
||||||
|
| FR-B041 | 播放 / 暂停 / 上一单位 / 下一单位 / 1x·1.5x·2x | P0 | 与 `<audio>` 同步 |
|
||||||
|
| FR-B042 | 单位列表展示已听 / 未听;断点续播 | P0 | 恢复正确单位 |
|
||||||
|
| FR-B043 | 整体模式:beat 文本 + refs 来源段落高亮 | P0 | — |
|
||||||
|
| FR-B044 | 逐段模式:原文 + 三层讲解,高亮跟随音频 | P0 | 至少句子级 |
|
||||||
|
| FR-B045 | 「看原文」跳转 PDF 对应页 | P0 | 页码与段落 page 一致 |
|
||||||
|
| FR-B046 | 点击列表任意行跳播 | P0 | Seek 到该单位起始 |
|
||||||
|
| FR-B047 | 段落「重点标记」★ | P0 | 写入进度并进入报告 |
|
||||||
|
|
||||||
|
### 5.6 问答(伴读上下文)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B050 | 文字提问,锚定 beat / 段落 / 句子 / 全局 | P0 | 上下文含锚点原文与讲解 |
|
||||||
|
| FR-B051 | 选中句子触发「解释这句」 | P0 | 翻译 + 句法 + 上下文作用 |
|
||||||
|
| FR-B052 | 回答基于 RAG:当前单位 + 摘要 + 检索相关段 | P0 | 长论文不整篇塞入 Prompt |
|
||||||
|
| FR-B053 | 回答文字流式显示,随后 TTS | P0 | 首 token ≤ 3 s |
|
||||||
|
| FR-B054 | Q&A 写入本地记录并纳入伴读报告 | P0 | 可按段落分组 |
|
||||||
|
|
||||||
|
> **与 Chat 模式关系**:伴读问答使用独立 `qa_record` 表与锚点模型;不复用 Chat 的 `sessions/*.json`,避免数据结构冲突。
|
||||||
|
|
||||||
|
### 5.7 伴读报告与参考文献
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B060 | 手动或条件触发「生成报告」 | P0 | 更新策略:同名 Note 覆盖 |
|
||||||
|
| FR-B061 | 报告结构:速览 / 收听 / 问答 / 重点 / 参考文献卡片 | P0 | Markdown 结构固定 |
|
||||||
|
| FR-B062 | 参考文献卡片含 BibTeX | P0 | 复用 `citeExport` |
|
||||||
|
| FR-B063 | Note 标题格式 `BanDu 伴读报告: {title}` | P0 | 与 Chat 笔记可区分 |
|
||||||
|
|
||||||
|
### 5.8 设置(共享 + 专属)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-S010 | LLM Provider / Base URL / API Key / Model | P0 | **共享**,两模式只读同一 prefs |
|
||||||
|
| FR-S011 | Chat 专属:system prompt、summary prompt、answer language | P0 | 已有,不变 |
|
||||||
|
| FR-S012 | 伴读专属:TTS Provider / API Key / 音色 | P0 | 新增 prefs 前缀或分组 |
|
||||||
|
| FR-S013 | 伴读专属:教师风格预设(简洁 / 详细) | P1 | 仅影响新生成内容 |
|
||||||
|
| FR-S014 | 数据流向说明(哪些文本发往外网) | P0 | 设置页固定文案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 非功能需求
|
||||||
|
|
||||||
|
| ID | 类别 | 需求描述 | 指标 |
|
||||||
|
|----|------|----------|------|
|
||||||
|
| NFR-D001 | 隔离性 | 伴读模块崩溃不影响 Chat pane 可用 | 异常捕获 + 独立 try/catch 边界 |
|
||||||
|
| NFR-D002 | 性能 | 8 页论文本地解析 | ≤ 3 min |
|
||||||
|
| NFR-D003 | 性能 | 缓存命中后打开伴读页 | ≤ 2 s 可播放 |
|
||||||
|
| NFR-D004 | 成本 | 单篇 8 页首次备课总 API 成本 | ≤ ¥10 |
|
||||||
|
| NFR-D005 | 隐私 | PDF 不上传;段落文本按请求发送 | Local-first |
|
||||||
|
| NFR-D006 | 离线 | 已缓存 Lecture + mp3 可离线播放 | 除问答外不依赖网络 |
|
||||||
|
| NFR-D007 | 可维护性 | UI / 领域 / Zotero 适配三层分离 | 见 ARCHITECTURE.md |
|
||||||
|
| NFR-D008 | 存储 | 伴读数据目录 `{Profile}/chatpapers/bandu/` | 与 `sessions/` 并列,不混用 |
|
||||||
|
| NFR-D009 | 兼容性 | Zotero 9.x | 与现有 manifest 一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据与存储策略
|
||||||
|
|
||||||
|
### 7.1 目录布局
|
||||||
|
|
||||||
|
```
|
||||||
|
{ZoteroProfile}/chatpapers/
|
||||||
|
├── sessions/ # Chat 模式(已有):*.json
|
||||||
|
└── bandu/ # 伴读模式(新增)
|
||||||
|
├── bandu.db # SQLite
|
||||||
|
└── audio/
|
||||||
|
└── {cacheKey}/
|
||||||
|
└── {unitId}.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 缓存键
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chat 模式继续使用 `itemKey + attachmentKey` 会话文件;伴读使用 `cacheKey`,**互不干扰**。
|
||||||
|
|
||||||
|
### 7.3 笔记命名约定
|
||||||
|
|
||||||
|
| 来源 | Note 标题前缀 | 覆盖策略 |
|
||||||
|
|------|---------------|----------|
|
||||||
|
| ChatPapers | `ChatPapers:` 或用户自定义 | 每次新建或用户选择 |
|
||||||
|
| BanDu | `BanDu 伴读报告:` | 同名更新,避免笔记爆炸 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 技术集成约束
|
||||||
|
|
||||||
|
### 8.1 建议目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── hooks.ts # 注册双 pane
|
||||||
|
├── modules/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── readerPane.ts # 注册 Chat + Bandu section
|
||||||
|
│ │ ├── chatView.ts # Chat 模式(不动)
|
||||||
|
│ │ └── prefs.ts # 扩展伴读设置分组
|
||||||
|
│ ├── llm/ # 共享
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── extractor.ts # 共享:附件定位
|
||||||
|
│ │ └── selection.ts # 共享:选区
|
||||||
|
│ ├── zotero/ # 共享:notes, citeExport
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ └── sessions.ts # Chat 专属(不动)
|
||||||
|
│ └── bandu/ # 伴读专属(新建)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── banduPane.ts
|
||||||
|
│ │ └── player/
|
||||||
|
│ ├── application/
|
||||||
|
│ ├── domain/
|
||||||
|
│ └── infrastructure/
|
||||||
|
│ ├── pdf/ # MinerU 封装
|
||||||
|
│ ├── tts/
|
||||||
|
│ ├── storage/ # SQLite
|
||||||
|
│ └── rag/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 依赖规则
|
||||||
|
|
||||||
|
```
|
||||||
|
bandu/* ──可引用──► llm/, pdf/extractor, pdf/selection, zotero/, utils/
|
||||||
|
chatView ──不可引用──► bandu/* (Chat 保持独立,避免耦合)
|
||||||
|
bandu/* ──不可引用──► chatView, storage/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Phase 0 验证项(开工前 Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | 第二 Item pane section 注册 | 与 Chat pane 并存无冲突 | 是 |
|
||||||
|
| P0-2 | MinerU 解析 3 篇论文 | 段落可用率 ≥ 80% | 是 |
|
||||||
|
| P0-3 | Zotero 9 插件内 SQLite 读写 | CRUD 正常 | 是 |
|
||||||
|
| P0-4 | `<audio>` 长音频播放 | 10 min+ 无泄漏 | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 + timestamps | 自然度可接受;无则确认降级 | 否 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 版本与路线图
|
||||||
|
|
||||||
|
### 9.1 与现有 ChatPapers 版本关系
|
||||||
|
|
||||||
|
| 插件版本 | Chat 模式 | 伴读模式 |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| v0.2.x(当前) | 对话 / 总结 / 多 PDF | — |
|
||||||
|
| v0.3.0 | 维护 | Phase 0 POC |
|
||||||
|
| v0.4.0 – v0.5.0 | 维护 | M1–M2:解析 + 备课 + TTS + 播放器 |
|
||||||
|
| v0.6.0 | 维护 | M3–M4:问答 RAG + 报告 + dogfooding |
|
||||||
|
| v1.0.0 | 稳定 | 伴读 MVP 功能完整 |
|
||||||
|
|
||||||
|
### 9.2 伴读模式开发里程碑
|
||||||
|
|
||||||
|
| 阶段 | 时间 | 交付 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 0 | 第 0 周 | 三项 POC + 双 pane 骨架占位 |
|
||||||
|
| M1 | 第 1–2 周 | MinerU 解析 + 段落树 + 备课流水线(纯文本) |
|
||||||
|
| M2 | 第 3–4 周 | TTS + 播放器双模式 + 高亮 |
|
||||||
|
| M3 | 第 5–6 周 | 锚定问答 RAG + 伴读报告回写 |
|
||||||
|
| M4 | 第 7–8 周 | 增量缓存 / 断点续播 / 设置 / dogfooding 10 篇 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. MVP 验收清单(伴读模式)
|
||||||
|
|
||||||
|
- [ ] 安装单一 ChatPapers `.xpi`,Item pane 同时出现 ChatPapers 与伴读两个标签
|
||||||
|
- [ ] LLM 配置一次,两模式均可调用
|
||||||
|
- [ ] 对 1 篇 8 页英文 PDF 完成:解析 → 备课 → 整体讲解播放
|
||||||
|
- [ ] 切换到逐段精读并完成至少 1 个章节播放
|
||||||
|
- [ ] 提问 3 次(含 1 次全局 + 1 次逐句),文字与语音均有
|
||||||
|
- [ ] 标记 2 个重点段落
|
||||||
|
- [ ] 生成伴读报告至 Zotero 子笔记,含 BibTeX;与 Chat 笔记共存不覆盖
|
||||||
|
- [ ] 关闭 Zotero 再打开,缓存命中、断点续播
|
||||||
|
- [ ] 断网重听已缓存内容
|
||||||
|
- [ ] Chat 模式原有功能回归通过(对话 / 总结 / 存笔记)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| Chat 模式 | ChatPapers 现有对话 / 总结工作流 |
|
||||||
|
| 伴读模式 / BanDu | 本插件内第二种工作流:备课 → 听课 → 报告 |
|
||||||
|
| 双模式 | 同一插件、两个 Item pane、共享 LLM 与 Zotero 集成 |
|
||||||
|
| Lecture | 一次备课产物:摘要 + beats + 逐段讲解 |
|
||||||
|
| Beat | 整体讲解播放单位,含 refs[] |
|
||||||
|
| 共享层 | llm / zotero / pdf 附件 / utils,两模式复用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/requirements.md ChatPapers 对话模式需求(不变)
|
||||||
|
docs/newidea/
|
||||||
|
├── PLAN.md BanDu 产品策划(参考)
|
||||||
|
├── PRD.md BanDu 独立 PRD(参考)
|
||||||
|
├── ARCHITECTURE.md BanDu 技术架构(参考)
|
||||||
|
└── DUAL_MODE_PRD.md ★ 本文:双模式集成需求(实施依据)
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施优先级**:开发伴读功能时,以**本文**为集成需求主文档;细节 Prompt、数据表、状态机见 ARCHITECTURE.md;产品动机与竞品见 PLAN.md。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 待决事项
|
||||||
|
|
||||||
|
| 事项 | 负责人 | 备注 |
|
||||||
|
|------|--------|------|
|
||||||
|
| TTS 提供商选型 | 技术 | Phase 0 |
|
||||||
|
| SQLite 实现方案(better-sqlite3 vs sql.js) | 技术 | Phase 0 |
|
||||||
|
| 伴读 pane 图标与 l10n 命名(BanDu / 伴读) | 产品 | M1 前 |
|
||||||
|
| Chat → 伴读上下文导入是否做 v1.1 | 产品 | MVP 不做 |
|
||||||
|
| 插件对外宣传:单品牌 vs 双品牌露出 | 产品 | MVP 后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 初稿:方案 A 双模式集成需求 |
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# 产品策划书:BanDu(伴读)— Zotero 插件
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 插件全名 | BanDu for Zotero |
|
||||||
|
|
||||||
|
> 一句话定位:**BanDu(伴读)——在 Zotero 里给每篇论文配一位 AI 老师**——先读完论文备好课,
|
||||||
|
> 再按你选的模式授课(整体讲解 / 逐段精读 / 逐句按需),随时提问、系统不打扰,
|
||||||
|
> 听完后把伴读笔记和参考文献卡片留在 Zotero,写论文时一键引用。
|
||||||
|
|
||||||
|
## 0. 一期范围声明(重要)
|
||||||
|
|
||||||
|
- **一期 = PC 端 + Zotero 插件**,这是产品本体,不是独立 App 的附属渠道;
|
||||||
|
- 论文库 = Zotero 现有库(零迁移),插件直接读 Zotero 里的 PDF;
|
||||||
|
- 移动端是**未来的独立产品**(v2+),通过云后端与 Zotero 端同步,不在一期考虑;
|
||||||
|
- 一期核心用户 = 自己(dogfooding)→ 再面向 Zotero 学术用户开放。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与机会
|
||||||
|
|
||||||
|
### 1.1 核心工作流(一期要服务的完整闭环)
|
||||||
|
|
||||||
|
```
|
||||||
|
Zotero 论文库(已有)
|
||||||
|
│ 打开某篇 PDF
|
||||||
|
▼
|
||||||
|
BanDu 插件:AI 老师读完整篇 → 备课 → 按模式授课 ── 随时提问 ── 标记重点
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
伴读产出留在 Zotero 里:
|
||||||
|
• 伴读报告(问答记录 + 重点标记 + 收听情况)→ Zotero 子笔记,可搜索
|
||||||
|
• 参考文献卡片(中文速览 + 推荐引用句 + BibTeX)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
写论文时:从 Zotero 直接导入引用 + 复用伴读总结(Related Work 素材)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 机会点
|
||||||
|
|
||||||
|
- 在 Zotero 生态里,**文字 AI 已是红海**(Paper Copilot、Owl 等:摘要/问答/翻译),
|
||||||
|
**"AI 老师式音频授课 + 段落/句子级交互"仍是空白**——即使桌面端,"边干活边听论文"也是真实场景;
|
||||||
|
- EndNote / Mendeley 无开放插件生态,**插件路线 = Zotero 路线**,护城河是生态卡位;
|
||||||
|
- Paper Copilot 的流行证明 Zotero 用户对论文 AI 的需求强且愿意装插件。
|
||||||
|
|
||||||
|
> 立项前需实测确认:Paper Copilot / Owl 最新版是否已加 TTS 朗读(目前判断没有,需录屏存档)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 竞品分析
|
||||||
|
|
||||||
|
| 产品 | 形态 | 段落级锚定 | 语音伴读 | 听完提问 | 与 Zotero 集成 | 差距 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Paper Copilot(Zotero 插件) | 插件 | ◐ | ✗ | ✓(文字) | ✓ | 无音频、无老师式授课、无伴读报告闭环 |
|
||||||
|
| Owl(Zotero 插件) | 插件 | ✗ | ✗ | ✓(文字) | ✓ | 同上 |
|
||||||
|
| SciSpace | 网页 | ✓ | ✗ | ✓(文字) | ✗ | 不在 Zotero 工作流里 |
|
||||||
|
| NotebookLM Audio | 网页 | ✗ | ◐(预生成播客,不可交互) | ✗ | ✗ | 不可交互、不可锚定、不在 Zotero |
|
||||||
|
| ChatGPT 语音 + PDF | App | ✗ | ✓ | ✓ | ✗ | 对话不锚定段落、无播放器、无笔记回写 |
|
||||||
|
| Speechify 类 | 多端 | ✗ | ✓(裸读) | ✗ | ✗ | 机器朗读,无 AI 讲解 |
|
||||||
|
|
||||||
|
**空白确认:Zotero 内 × 老师式授课(整体+逐段+逐句)× 随时提问 × 伴读报告回写,同时满足的产品不存在。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心设计决策
|
||||||
|
|
||||||
|
### 3.0 心智模型:AI = 论文老师(不是 TTS 播放器)
|
||||||
|
|
||||||
|
产品的概念基础是一位"**先读完整篇论文、再给读者讲解的老师**",四个阶段:
|
||||||
|
|
||||||
|
```
|
||||||
|
① 读 读完整篇论文(解析 + 段落索引 + 全文摘要)
|
||||||
|
② 备课 基于全文理解生成"讲义"(Lecture 对象,缓存复用):
|
||||||
|
- 整体讲解脚本(按教学逻辑组织,不按论文物理顺序)
|
||||||
|
- 逐段三层讲解(按论文结构组织)
|
||||||
|
- (按需)逐句讲解
|
||||||
|
③ 授课 读者自选模式收听;随时可提问(读者发起,系统不打扰)
|
||||||
|
④ 复盘 听完后生成伴读报告/笔记 → 回写 Zotero,为写论文提供素材
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键架构含义:②"备课"是一次性、可缓存的产物。**
|
||||||
|
老师备一次课,读者听 N 次、换模式重听、隔周再听,都不再花 LLM 成本;
|
||||||
|
整体讲解与逐段精读**共享同一份备课**(同一个全文摘要上下文),边际成本只多一次生成。
|
||||||
|
|
||||||
|
### 3.1 多种授课模式(粒度 = 模式,不是全局开关)
|
||||||
|
|
||||||
|
| 模式 | 组织逻辑 | 播放单位 | 场景 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **整体讲解** | **教学逻辑**:问题→动机→核心方法→关键证据→结论与局限(不是论文物理顺序) | beat(30s–2min 讲解片段,全篇 8–15 个) | 第一次过论文,听"具体讲了什么、核心是什么" |
|
||||||
|
| **逐段精读** | 论文物理顺序:章节→段落 | 段落(>150 词的长段拆 1–3 个"要点") | 第二次精读,吃透方法/实验 |
|
||||||
|
| **逐句讲解** | 按需:选中任意句子,问"解释这句" | 句 | 硬句子手术 |
|
||||||
|
|
||||||
|
**逐段与逐句并存的理由**:老师的粒度应随内容难度走——
|
||||||
|
简单章节整体带过、核心章节逐段精读、难句子逐句解剖。
|
||||||
|
**"整篇逐句自动播放"不做**(成本高、收益低),但逐句**按需解释**永远可用;
|
||||||
|
v1.1 加"**难点句清单**":备课时 AI 主动标出全篇 5–10 个最难读的句子,逐个听讲解
|
||||||
|
(比整篇逐句性价比高得多的折中)。
|
||||||
|
|
||||||
|
**整体讲解的 beat ≠ 段落(关键数据结构差异)**:老师会重组材料
|
||||||
|
("这个 motivation 其实在 p12,我们回去看一下")。
|
||||||
|
每个 beat 携带 `refs[]` 指回来源段落,用于文字面板同步高亮对应原文、点击"看原文"跳 PDF 页。
|
||||||
|
|
||||||
|
v2:**检验模式**——老师反考读者("你觉得作者为什么这么设计?",答完给反馈),
|
||||||
|
直接服务"提高理解能力"的终极目标。
|
||||||
|
|
||||||
|
### 3.2 老师讲什么:三种内容模型
|
||||||
|
|
||||||
|
**① 整体讲解 beat**(全篇 10–20 分钟 ≈ 一杯咖啡过完论文核心):
|
||||||
|
- 讲解文本(中文口语)
|
||||||
|
- `refs[]`:支撑该 beat 的来源段落 ID
|
||||||
|
- 类型标签:问题引入 / 核心方法 / 关键实验 / 结论局限 / 延伸思考
|
||||||
|
- 质量要求(防"像 Wikipedia 摘要"):每个 beat 必须含 **为什么重要 / 一个具体例子或数字 / 一个易混淆点** 三要素
|
||||||
|
|
||||||
|
**② 逐段三层讲解**(对应核心诉求"什么意思 / 怎么理解 / 整体研究思路"):
|
||||||
|
1. **讲了什么**(白话翻译/转述;公式转口语不念 LaTeX;专有名词保留英文 + 中文解释)
|
||||||
|
2. **怎么理解**(关键概念、重要数字、和常规做法的差异)
|
||||||
|
3. **全文定位**("这是 motivation 收尾,为下一节 method 引出问题")
|
||||||
|
|
||||||
|
**③ 逐句讲解**(按需触发):句翻译 + 句法拆解(长句怎么断)+ 这句话在上下文的作用。
|
||||||
|
|
||||||
|
**教师风格(prompt 层可配置)**:主动"点"、不主动"问"——
|
||||||
|
提示性内容(易混淆点、作者隐含假设、"注意这里做了一个强假设")**写进生成的讲义里**,
|
||||||
|
运行时不弹任何提问提示(见 3.3)。
|
||||||
|
|
||||||
|
**语言处理**:
|
||||||
|
- 英文论文:音频 = 中文讲解(不是英文裸读);文字面板 = 英文原文 + 中文讲解对照;
|
||||||
|
- 中文论文:音频 = 中文"白话简化 + 方法分析 + 全文定位"(价值不在翻译,在降维讲解);
|
||||||
|
- 自动检测论文语言,切换 prompt 模板;v1.1 可选"原文朗读模式"(英文 TTS 练听力)。
|
||||||
|
|
||||||
|
### 3.3 交互:读者发起,系统不打扰
|
||||||
|
|
||||||
|
- **不做**每段/每句播完的"这段有什么疑问吗?"提示(已确认砍掉);
|
||||||
|
- 交互入口(任意时刻):
|
||||||
|
1. **随时提问**:锚定当前位置(当前 beat / 段落),也可问全局
|
||||||
|
("这篇和 Transformer 什么关系?");
|
||||||
|
2. **暂停即问**:暂停是天然交互点,暂停时提问入口高亮;
|
||||||
|
3. **选中句子问**:"解释这句"(逐句讲解的入口)。
|
||||||
|
- 问答上下文:当前单位原文 + 对应讲解 + 全文摘要 + 全文索引(RAG);
|
||||||
|
回答**文字先行显示 + TTS 流式播放**;所有问答沉淀进伴读记录(进报告)。
|
||||||
|
- v1.1:章节边界软提示(**默认关闭**:"方法部分讲完了,要问点什么吗?")。
|
||||||
|
|
||||||
|
**系统必须答好的问题类型**(prompt 与 RAG 设计依据):
|
||||||
|
翻译类("这句什么意思")/ 理解类("为什么不用 X")/ 逻辑类("结论怎么从实验得出")/
|
||||||
|
关联类("这和前面 Y 什么关系")/ 元认知类("这段在全文什么作用")。
|
||||||
|
|
||||||
|
### 3.4 伴读报告(听后沉淀,存成 Zotero 笔记)
|
||||||
|
|
||||||
|
听完(或随时点"生成报告")自动汇总:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 伴读报告:<论文标题>
|
||||||
|
## 论文速览 问题/方法/结果/局限(中文四句)
|
||||||
|
## 收听情况 模式、已听 x%、已听段落列表
|
||||||
|
## 问答记录 按段落:Q + AI 回答(可重播)
|
||||||
|
## 重点标记 读者标记的段落 + AI 一句话理由
|
||||||
|
## 参考文献卡片 一句话总结 + 推荐引用句 + BibTeX(BBT citation key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 存为 Zotero 子笔记(markdown,挂 item 下、可搜索),可导出 .md;
|
||||||
|
- **为写论文服务**:推荐引用句 + 速览可直接进 related work,BibTeX 走 Zotero 原生管道;
|
||||||
|
后续写相关论文时,Zotero 里搜论文标题即可调出全部伴读素材;
|
||||||
|
- v1.1+:理解度评估(按提问分布:"你在实验部分提问最多,建议重听该章节")。
|
||||||
|
|
||||||
|
### 3.5 与 Zotero 的深度集成(一期核心卖点)
|
||||||
|
|
||||||
|
| 集成点 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 读 PDF | 插件取 item 的 PDF attachment 本地路径,直接读,**论文不出 Zotero** |
|
||||||
|
| 触发 | 选中带 PDF 的 item → Item pane 出现"伴读"标签页 |
|
||||||
|
| 缓存 | 本地 SQLite:Lecture 对象(beats/三层讲解)+ mp3 + 问答记录,key = attachment ID + 文件哈希;二次打开秒进、离线可重听 |
|
||||||
|
| 伴读报告回写 | 报告 + 每段 Q&A + 重点标记 → `Zotero.Note` 子笔记 |
|
||||||
|
| 参考文献卡片 | 中文速览(问题/方法/结果/局限)+ 推荐引用句 + BibTeX 条目(有 Better BibTeX 时取 citation key) |
|
||||||
|
| 写论文导入 | 写 related work 时:卡片摘要可直接粘贴,BibTeX 经 Zotero 原生/BBT 进 LaTeX;重点段落笔记提供引用上下文 |
|
||||||
|
| 伴读库视图 | 插件主窗口列全部论文:未备课 / 已备课 / 已听 x% / 有报告,快速进入 |
|
||||||
|
|
||||||
|
### 3.6 播放器 UI(Zotero Item pane 内的"伴读"页)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 论文标题 [英] 老师已备课 ✓ [备课进度(首次)] │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ 模式: [整体讲解 | 逐段精读] │
|
||||||
|
│ ▶ ⏸ ⏮ ⏭ 1x/1.5x/2x │
|
||||||
|
├────────────────────────┬─────────────────────┤
|
||||||
|
│ 单位列表: │ 当前内容: │
|
||||||
|
│ 整体讲解: │ 整体模式: beat 文本 │
|
||||||
|
│ b1 问题引入 ●已讲 │ + refs 来源段落高亮 │
|
||||||
|
│ b2 核心方法 ○ │ 逐段模式: 原文(英)+ │
|
||||||
|
│ b3 关键实验 ○ │ 三层中文讲解(高亮跟随) │
|
||||||
|
│ 逐段精读: │ ── 提问(随时)── │
|
||||||
|
│ 2 Method │ Q: ... [重播] │
|
||||||
|
│ p17 ●已听 [问2] [★] │ A: ... [重播] │
|
||||||
|
│ p18 ○ │ [输入框: 问当前段/全局] │
|
||||||
|
│ ... │ [选中句子→解释这句] │
|
||||||
|
└────────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 文字面板逐句/逐词高亮跟随音频(TTS word timestamps);
|
||||||
|
- 整体讲解模式:面板高亮当前 beat 的 `refs[]` 来源段落原文,可点"看原文"跳 PDF 页;
|
||||||
|
- 点列表任意行跳播;播放中列表自动滚动跟随;断点续播。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 技术架构(Local-first)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Zotero 7 (PC, macOS/Win) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 插件 (TypeScript, 官方 zotero-plugin boilerplate) │ │
|
||||||
|
│ │ • UI 层:伴读 pane / 伴读库窗口 (HTML5 + <audio>) │ │
|
||||||
|
│ │ • 本地引擎:PDF 解析 (MinerU/Marker, 本地 CPU 跑) │ │
|
||||||
|
│ │ • 本地存储:SQLite(Lecture 对象/问答/进度) + mp3 文件│ │
|
||||||
|
│ │ • 设置:LLM 提供商+key / TTS 提供商 / 教师风格 │ │
|
||||||
|
│ │ • 回写:Zotero.Note / 附件路径 / BBT citation key │ │
|
||||||
|
│ └──────────────┬─────────────────────────────────────┘ │
|
||||||
|
└─────────────────┼──────────────────────────────────────┘
|
||||||
|
│ (仅发送段落文本/提问,论文库本身不上云)
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
│ LLM API │ TTS API
|
||||||
|
│ (用户自带 key; │ (ElevenLabs/Azure/
|
||||||
|
│ 或 Ollama 本地) │ 火山/MiniMax; mp3 落盘)
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 关键流水线
|
||||||
|
|
||||||
|
1. **解析**:attachment 路径 → 本地 layout 切段(双栏/公式/表格)→ 段落树(章节/段落/要点三层)→ SQLite;
|
||||||
|
2. **备课**(Lecture 对象,一次生成、缓存复用):
|
||||||
|
a. 全文摘要(问题/方法/结果/局限)→ 作为后续所有生成的全局上下文;
|
||||||
|
b. **整体讲解 beats**(8–15 个,教学逻辑序,每个 beat 带 `refs[]` 来源段落 ID;
|
||||||
|
prompt 只允许引用已分配的段落 ID,**生成后校验,非法引用剔除**);
|
||||||
|
c. **逐段三层讲解**(按 5 段一批,带章节标题 + 全文摘要上下文);
|
||||||
|
d. v1.1:难点句清单(5–10 句,带句内定位);
|
||||||
|
→ **增量备课**:全文摘要 + beats 先生成(约 1–2 min),即可开听整体讲解;逐段内容后台继续生成("边备课边开讲")。
|
||||||
|
3. **合成**:讲解文本 → TTS → mp3 + word timestamps → 落盘(按 beat / 段落分片);
|
||||||
|
4. **播放**:三种模式共用同一播放器引擎(beat 序 / 段落序 / 单句);
|
||||||
|
word timestamps 驱动文字面板高亮;整体模式按 beat 的 `refs[]` 高亮来源段落;
|
||||||
|
5. **问答**(随时发起):锚定当前单位(beat/段落/句子)→ 组装 prompt
|
||||||
|
(当前单位原文 + 讲解 + 全文摘要 + RAG 相关段)→ LLM 流式 → 文字即时显示 → TTS 流式播放;问答写入伴读记录;
|
||||||
|
6. **报告**:收听完成或手动触发 → 伴读报告 + 参考文献卡片 → Zotero Note / 导出 .md。
|
||||||
|
|
||||||
|
### 4.2 关键技术难点与对策
|
||||||
|
|
||||||
|
| 难点 | 对策 |
|
||||||
|
|---|---|
|
||||||
|
| 双栏/公式/表格切分质量 | 首选 MinerU(CPU 可跑、公式识别好),Marker 备选;解析结果在文字面板可见,bad case 可兜底 |
|
||||||
|
| beat 的 refs 幻觉(引用不存在的段落) | prompt 约束只引用已分配 ID + 生成后校验剔除;refs 缺失的 beat 降级为纯讲解(不高亮原文) |
|
||||||
|
| 整体讲解"太泛"(像 Wikipedia 摘要,不像老师) | beat 模板强制三要素(为什么重要/具体例子或数字/易混淆点);dogfooding 迭代 prompt |
|
||||||
|
| PDF viewer 内段落高亮 | 依赖 PDF viewer 内部 API,稳定性未验证 → **MVP 不做**,用"文字面板高亮 + PDF 页跳转";v1.1 调研 |
|
||||||
|
| 音文同步 | 优先 TTS 提供商返回的 word boundary;缺失则 WhisperX 强制对齐(离线跑一次) |
|
||||||
|
| 问答延迟 | LLM 流式 + TTS 流式,首句 < 2–3s;文字答案先行显示,音频随后 |
|
||||||
|
| LLM 幻觉(讲解偏离原文) | prompt 严格 grounding 到段落原文;温度低;原文始终并排可见;"这段讲解"可一键重生成 |
|
||||||
|
| 长论文成本 | 备课一次、终身缓存(本地);单篇总成本 < ¥10,一次性;二次打开零成本、可离线重听 |
|
||||||
|
| 隐私 | local-first:PDF 不出机器、只发段落文本给 LLM API;设置支持 Ollama 纯本地模型(质量换隐私) |
|
||||||
|
| Zotero 7 API 较新 | 官方 boilerplate、pin 版本;UI 只依赖稳定 API(pane/notes/attachments) |
|
||||||
|
|
||||||
|
### 4.3 单篇成本/耗时估算(8 页英文论文)
|
||||||
|
|
||||||
|
- 解析:本地 CPU 1–3 min,免费
|
||||||
|
- 备课 LLM:全文摘要 + 12 beats + 50–70 段三层讲解 ≈ 30k token ≈ $0.05–0.3(一次性)
|
||||||
|
- TTS:整体讲解 ≈ 3k–5k 中文字 + 逐段 ≈ 8k–15k 字 ≈ ¥1–5(火山/Azure)或 $0.2–0.6(ElevenLabs flash)
|
||||||
|
- 问答:按需,每轮 ≈ $0.002 + 几分钱 TTS
|
||||||
|
- **二次打开 / 换模式重听:零成本**(全部本地缓存,离线可听)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MVP 范围(6–8 周,PC + Zotero 插件)
|
||||||
|
|
||||||
|
**MVP 一句话:在 Zotero 里打开一篇英文论文,点"备课",AI 读完整篇后按你选的模式授课
|
||||||
|
(整体讲解 / 逐段精读),可随时提问并听到语音回答,听完后伴读报告与参考文献卡片留在 Zotero 里。**
|
||||||
|
|
||||||
|
✅ 包含:
|
||||||
|
1. Zotero 7 插件骨架 + Item pane "伴读"标签页 + 伴读库主窗口(简版)
|
||||||
|
2. 读 Zotero 附件 PDF → 本地解析 → 段落树(章节/段落/要点)
|
||||||
|
3. **备课流水线**:全文摘要 + 整体讲解 beats(含 refs 校验)+ 逐段三层讲解;增量备课(边备边开讲)
|
||||||
|
4. TTS 逐单元合成 + 本地 mp3 缓存
|
||||||
|
5. 播放器:**模式切换(整体讲解 / 逐段精读)**、播放/暂停/跳单位/语速 + 列表 + 文字面板高亮 + PDF 页跳转
|
||||||
|
6. 随时提问(文字输入,锚定当前位置或全局)→ 文字先行 + 语音回答 + 追问;选中句子问"解释这句"(逐句按需)
|
||||||
|
7. 伴读报告(问答记录/重点标记/收听情况)→ Zotero 子笔记;参考文献卡片 + BibTeX
|
||||||
|
8. 设置页(LLM/TTS 提供商与 key、教师风格)、断点续播
|
||||||
|
9. Dogfooding:自己读 10 篇论文,迭代 prompt 与节奏
|
||||||
|
|
||||||
|
❌ 明确砍到 v1.1+:
|
||||||
|
- 语音输入提问(ASR)、barge-in 打断(MVP 用打字提问)
|
||||||
|
- 章节边界软提示(默认关闭)、难点句清单、PDF 内段落级高亮、原文朗读模式
|
||||||
|
- 检验模式(老师考读者,v2)
|
||||||
|
- 移动端 App、云同步、多用户/订阅
|
||||||
|
|
||||||
|
**MVP 验证的假设:**
|
||||||
|
1. 整体讲解 10–20 分钟的信息密度是否合适 → dogfooding 调 beat 数与单 beat 长度;
|
||||||
|
2. "无提示、随时问"的交互是否够用 → 记录自己"想问但没问"的时刻,决定 v1.1 软提示的默认值;
|
||||||
|
3. 伴读报告回 Zotero 后,写论文时是否真的被复用(**关键闭环验证**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 路线图
|
||||||
|
|
||||||
|
| 阶段 | 时间 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | 第 1–2 周 | 插件骨架 + PDF 解析(MinerU 本地)+ 段落树 + 备课流水线(摘要/beats/三层讲解,纯文本跑通) |
|
||||||
|
| M2 | 第 3–4 周 | TTS 流水线 + 播放器(双模式切换)+ 文字面板高亮 + refs 高亮 |
|
||||||
|
| M3 | 第 5–6 周 | 随时问答(RAG + 流式)+ 伴读报告/卡片回写 + BibTeX |
|
||||||
|
| M4 | 第 7–8 周 | 增量备课/缓存/断点续播 + 设置页 + dogfooding 10 篇 + 打磨 |
|
||||||
|
| v1.1 | +4–6 周 | 语音输入(ASR)、barge-in、难点句清单、软提示(默认关)、PDF 内高亮、原文朗读模式、理解度评估、伴读库完善、开源核心 |
|
||||||
|
| v2 | 后续 | **独立移动端 App**(云后端同步论文与伴读进度)、**检验模式**(老师考读者)、多论文对比听、团队版/订阅 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 商业模式(分期)
|
||||||
|
|
||||||
|
- **一期**:个人工具,插件免费 + 用户自带 LLM/TTS key(零边际成本,dogfooding + 口碑);
|
||||||
|
- **v1.1**:核心开源,Pro 云服务(TTS 代付、跨设备同步、热门 arXiv 论文讲义共享缓存)$9.9–19.9/月;
|
||||||
|
- **长期**:学术团队版(共享伴读库、写作引用协作);移动端 App 跟随 v2。
|
||||||
|
- 成本结构:local-first 下服务器成本极低,主要成本在 TTS 代付(共享缓存可摊薄)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 等级 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 整体讲解"太泛"不像老师(核心体验风险) | 中 | beat 三要素模板 + dogfooding 迭代;教师风格可配置 |
|
||||||
|
| Zotero 7 插件 API 不稳定 / 未来版本变动 | 中 | 官方 boilerplate、pin 版本、只依赖稳定 API;UI 层与 API 层隔离 |
|
||||||
|
| PDF viewer 高亮依赖内部 API | 中 | MVP 绕开(文字面板高亮 + 页跳转);v1.1 专项调研 |
|
||||||
|
| LLM 讲解幻觉 / 质量不稳定 | 中 | grounding prompt + 原文并排 + 一键重生成 + dogfooding 迭代 prompt |
|
||||||
|
| 无提示交互下用户忘了提问(交互率过低) | 低 | dogfooding 记录数据;v1.1 软提示(默认关)兜底 |
|
||||||
|
| 大厂在 Zotero 生态做音频伴读(或 Zotero 官方 AI 化) | 中 | 生态卡位 + "备课-授课-报告"闭环是差异化;持续盯 Paper Copilot 与 Zotero roadmap |
|
||||||
|
| 隐私顾虑(论文文本发给 LLM API) | 低 | local-first + 可选 Ollama 纯本地;设置中明示数据流向 |
|
||||||
|
| 解析 bad case(复杂版面) | 中 | 双引擎备选 + 文字面板兜底 + 用户反馈入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 团队与预算(一期)
|
||||||
|
|
||||||
|
- 1 名全栈(插件 + 后端流水线)+ 1 名 AI 工程(解析/prompt/RAG,可同一人);
|
||||||
|
- 云成本 ≈ 0(全 local-first,仅 LLM/TTS API 按量,个人使用 <$10/月);
|
||||||
|
- 8 周 dogfooding 就绪,不含人力的直接成本 <$200。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 下一步行动(本周)
|
||||||
|
|
||||||
|
1. **竞品复核**:装 Paper Copilot + Owl,实测是否已有 TTS/音频能力,录屏存档;
|
||||||
|
2. **解析 POC**:取自己 Zotero 里 3 篇典型论文(双栏 + 公式密集)跑 MinerU(本地 CPU),评估段落切分与公式识别质量;
|
||||||
|
3. **插件 POC**:zotero-plugin boilerplate 跑通三件事——取 attachment 路径、Item pane 加标签页、页面里 `<audio>` 播一段 mp3(1–2 天);
|
||||||
|
4. **TTS 选型**:中文自然度 + 价格 + 是否返回 word timestamps,对比 ElevenLabs / Azure / 火山,各合成 30 秒样本试听;
|
||||||
|
5. **Prompt 设计**:写三套 prompt 初版——全文摘要 / 整体讲解 beats(含 refs 约束)/ 逐段三层讲解;用 1 篇论文人工评估"像不像老师"。
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 产品需求规格说明书(PRD)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | BanDu(伴读)— Zotero 插件 |
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 关联文档 | [PLAN.md](./PLAN.md)(产品策划)、[ARCHITECTURE.md](./ARCHITECTURE.md)(架构设计) |
|
||||||
|
| 一期范围 | Zotero 7 桌面插件(macOS / Windows) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品概述
|
||||||
|
|
||||||
|
### 1.1 一句话定位
|
||||||
|
|
||||||
|
在 Zotero 里给每篇论文配一位 AI 老师:先读完整篇论文并备好课,再按用户选择的模式授课(整体讲解 / 逐段精读 / 逐句按需),随时提问且系统不主动打扰;听完后将伴读笔记与参考文献卡片写回 Zotero,供写论文时复用。
|
||||||
|
|
||||||
|
**品牌**:中文名 **伴读**,英文名 **BanDu**(「伴读」音译)。
|
||||||
|
|
||||||
|
### 1.2 产品目标
|
||||||
|
|
||||||
|
| 目标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| G1 降低读论文门槛 | 用户可在 10–20 分钟内通过「整体讲解」把握论文核心 |
|
||||||
|
| G2 支持深度精读 | 通过「逐段精读」与「逐句按需解释」吃透方法与实验 |
|
||||||
|
| G3 闭环沉淀知识 | 伴读报告、问答、重点标记留在 Zotero,可直接用于 Related Work |
|
||||||
|
| G4 零迁移成本 | 论文库沿用 Zotero,PDF 不出本地机器 |
|
||||||
|
|
||||||
|
### 1.3 成功指标(MVP 验证期)
|
||||||
|
|
||||||
|
| 指标 | 目标 | 测量方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 整体讲解完成率 | ≥ 60% 启动后听完 | 播放进度日志 |
|
||||||
|
| 单篇备课可听时间 | 摘要 + beats 生成后 ≤ 2 min 可开听 | 备课流水线耗时 |
|
||||||
|
| 问答首字延迟 | ≤ 3 s(文字) | 问答链路埋点 |
|
||||||
|
| 报告复用率 | Dogfooding 10 篇中 ≥ 3 篇报告内容被粘贴进写作 | 人工记录 |
|
||||||
|
| 解析可用率 | 典型 8 页 NLP 论文 ≥ 90% 段落无需手动修正 | Dogfooding |
|
||||||
|
|
||||||
|
### 1.4 目标用户
|
||||||
|
|
||||||
|
| 角色 | 描述 | 一期优先级 |
|
||||||
|
|------|------|------------|
|
||||||
|
| 研究者(Primary) | 使用 Zotero 管理文献,需快速过大量论文并精读关键篇 | P0 |
|
||||||
|
| 研究生 | 英文论文阅读吃力,需要中文讲解与随时提问 | P0 |
|
||||||
|
| 论文写作者 | 需要 Related Work 素材、引用句与 BibTeX | P0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 范围定义
|
||||||
|
|
||||||
|
### 2.1 一期包含(MVP)
|
||||||
|
|
||||||
|
- Zotero 7 插件:Item pane「伴读」标签页 + 伴读库主窗口(简版)
|
||||||
|
- 读取 Zotero PDF 附件 → 本地解析 → 段落树
|
||||||
|
- 备课流水线:全文摘要 + 整体讲解 beats + 逐段三层讲解;增量备课
|
||||||
|
- TTS 合成 + 本地 mp3 缓存
|
||||||
|
- 播放器:整体讲解 / 逐段精读双模式、跳播、语速、文字面板高亮、PDF 页跳转
|
||||||
|
- 文字提问(锚定当前位置 / 全局 / 选中句子)→ 文字 + 语音回答
|
||||||
|
- 伴读报告 + 参考文献卡片 → Zotero 子笔记;BibTeX
|
||||||
|
- 设置页(LLM / TTS / 教师风格)、断点续播、本地缓存
|
||||||
|
|
||||||
|
### 2.2 一期明确不包含
|
||||||
|
|
||||||
|
| 功能 | 计划版本 |
|
||||||
|
|------|----------|
|
||||||
|
| 语音输入提问(ASR)、barge-in 打断 | v1.1 |
|
||||||
|
| 章节边界软提示 | v1.1 |
|
||||||
|
| 难点句清单(备课预标) | v1.1 |
|
||||||
|
| PDF viewer 内段落级高亮 | v1.1 |
|
||||||
|
| 英文原文朗读模式 | v1.1 |
|
||||||
|
| 检验模式(老师考读者) | v2 |
|
||||||
|
| 移动端 App、云同步、多用户订阅 | v2 |
|
||||||
|
|
||||||
|
### 2.3 不支持场景(需在 UI 明示)
|
||||||
|
|
||||||
|
- 扫描版 PDF(无 OCR 文本层)
|
||||||
|
- Item 无 PDF 附件或非 PDF 附件
|
||||||
|
- 加密且无法本地读取的 PDF
|
||||||
|
- 极长论文(> 80 页)默认仅推荐整体讲解模式(逐段按需生成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 用户故事与用例
|
||||||
|
|
||||||
|
### 3.1 核心用户故事
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… | 优先级 |
|
||||||
|
|----|-------|---------|-------|--------|
|
||||||
|
| US-01 | 研究者 | 选中 Zotero 论文后一键「备课」 | AI 先读完整篇再给我讲解 | P0 |
|
||||||
|
| US-02 | 研究者 | 选择「整体讲解」模式收听 | 20 分钟内把握论文核心 | P0 |
|
||||||
|
| US-03 | 研究者 | 选择「逐段精读」按章节听 | 深入理解方法与实验 | P0 |
|
||||||
|
| US-04 | 研究者 | 播放中随时文字提问 | 不打断思路又能澄清疑问 | P0 |
|
||||||
|
| US-05 | 研究者 | 选中难句点「解释这句」 | 快速理解长难句 | P0 |
|
||||||
|
| US-06 | 研究者 | 听完后自动生成伴读报告到 Zotero | 写论文时搜索即可调出素材 | P0 |
|
||||||
|
| US-07 | 研究者 | 二次打开同一篇论文秒进、离线重听 | 不重复花 API 费用 | P0 |
|
||||||
|
| US-08 | 研究者 | 在伴读库看到全部论文的备课/收听状态 | 快速找到未读论文 | P1 |
|
||||||
|
| US-09 | 研究者 | 标记重点段落 | 报告里汇总我关心的部分 | P0 |
|
||||||
|
| US-10 | 研究者 | 配置自己的 LLM / TTS API Key | 控制成本与隐私 | P0 |
|
||||||
|
|
||||||
|
### 3.2 用例:首次伴读一篇论文
|
||||||
|
|
||||||
|
**前置条件**:Zotero 7 已安装插件;Item 有 PDF 附件;用户已配置 LLM Key。
|
||||||
|
|
||||||
|
**主流程**:
|
||||||
|
|
||||||
|
1. 用户在 Zotero 选中带 PDF 的 Item
|
||||||
|
2. 打开 Item pane「伴读」标签页
|
||||||
|
3. 点击「开始备课」
|
||||||
|
4. 系统解析 PDF,展示进度(解析 → 摘要 → beats → 逐段讲解 → TTS)
|
||||||
|
5. 摘要 + beats 就绪后(约 1–2 min),整体讲解模式可用,用户开始收听
|
||||||
|
6. 用户可随时暂停、提问、切换逐段精读、标记重点
|
||||||
|
7. 用户点击「生成报告」或收听达阈值后,系统创建 Zotero 子笔记
|
||||||
|
|
||||||
|
**后置条件**:本地 SQLite + mp3 缓存就绪;Zotero 子笔记(若生成)可搜索。
|
||||||
|
|
||||||
|
### 3.3 用例:缓存命中二次打开
|
||||||
|
|
||||||
|
**前置条件**:该 attachment 已备课且文件哈希未变。
|
||||||
|
|
||||||
|
**主流程**:
|
||||||
|
|
||||||
|
1. 用户打开同一 Item 的伴读页
|
||||||
|
2. 系统校验 attachment ID + 文件哈希,命中缓存
|
||||||
|
3. 立即展示播放器,恢复上次播放进度
|
||||||
|
4. 不调用 LLM / TTS(除非用户触发重生成或问答)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能需求
|
||||||
|
|
||||||
|
### 4.1 插件与 Zotero 集成
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-001 | 插件基于 Zotero 7 官方 boilerplate,支持 macOS 与 Windows | P0 | 两平台可安装、启用 |
|
||||||
|
| FR-002 | 选中含 PDF 附件的 Item 时,Item pane 显示「伴读」标签页 | P0 | 无 PDF 时标签页禁用并提示原因 |
|
||||||
|
| FR-003 | 通过 Zotero API 读取 PDF attachment 本地路径 | P0 | 路径可读、文件哈希可计算 |
|
||||||
|
| FR-004 | 伴读报告写入 Item 下 `Zotero.Note` 子笔记(Markdown) | P0 | 笔记在 Zotero 内可搜索、可导出 .md |
|
||||||
|
| FR-005 | 有 Better BibTeX 时读取 citation key;无则降级 | P1 | 卡片中 BibTeX 字段正确或标注缺失 |
|
||||||
|
| FR-006 | 伴读库窗口列出库内论文及状态:未备课 / 已备课 / 已听 x% / 有报告 | P1 | 点击条目进入对应伴读页 |
|
||||||
|
|
||||||
|
### 4.2 PDF 解析与段落树
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-010 | 本地解析 PDF,输出章节 / 段落 / 要点三层结构 | P0 | 段落含唯一 ID、原文、页码、章节路径 |
|
||||||
|
| FR-011 | 支持双栏、公式、表格的常见学术论文版式 | P0 | Dogfooding 3 类典型论文可用 |
|
||||||
|
| FR-012 | 解析结果持久化至 SQLite,key = attachment ID + 文件哈希 | P0 | PDF 未变时跳过重复解析 |
|
||||||
|
| FR-013 | 解析进度与错误对用户可见 | P0 | 失败时给出可行动提示(如扫描版) |
|
||||||
|
| FR-014 | 文字面板展示解析原文,供用户核对 | P1 | 用户可发现 bad case |
|
||||||
|
|
||||||
|
### 4.3 备课(Lecture 生成)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-020 | 生成全文摘要:问题 / 方法 / 结果 / 局限 | P0 | 四字段非空、中文 |
|
||||||
|
| FR-021 | 生成 8–15 个整体讲解 beat,按教学逻辑排序 | P0 | 每 beat 含讲解文本、类型标签、refs[] |
|
||||||
|
| FR-022 | beat 的 refs[] 仅允许引用已存在段落 ID;生成后校验,非法引用剔除 | P0 | 校验日志可审计;无效 refs 的 beat 降级 |
|
||||||
|
| FR-023 | 每个 beat 含三要素:为什么重要 / 具体例子或数字 / 易混淆点 | P0 | Prompt 模板约束 + 抽检 |
|
||||||
|
| FR-024 | 为每个段落生成三层讲解:讲了什么 / 怎么理解 / 全文定位 | P0 | 三层均非空 |
|
||||||
|
| FR-025 | 增量备课:摘要 + beats 完成后即可播放整体讲解 | P0 | 用户无需等逐段全部完成 |
|
||||||
|
| FR-026 | 逐段讲解后台继续生成,UI 显示进度 | P0 | 新段落就绪后可播放 |
|
||||||
|
| FR-027 | Lecture 对象本地缓存,二次打开不重复生成 | P0 | 除非文件哈希变化或用户强制重备 |
|
||||||
|
| FR-028 | 支持一键重生成单个 beat 或单段讲解 | P1 | 重生成后更新缓存与音频 |
|
||||||
|
|
||||||
|
### 4.4 TTS 与音频
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-030 | 讲解文本按 beat / 段落分片合成 mp3 并本地缓存 | P0 | 文件路径与 Lecture 单元绑定 |
|
||||||
|
| FR-031 | 英文论文音频为中文讲解;自动检测论文语言切换 Prompt | P0 | 英/中论文各测 1 篇 |
|
||||||
|
| FR-032 | 保存 word / character timestamps(若 TTS 提供) | P0 | 用于文字面板高亮 |
|
||||||
|
| FR-033 | TTS 不支持 timestamps 时,降级为句子级或段落级高亮 | P0 | UI 不崩溃,高亮粒度下降可接受 |
|
||||||
|
| FR-034 | 问答回答支持 TTS 播放(可先整段合成,非必须流式) | P0 | 每轮 Q&A 可重播语音 |
|
||||||
|
|
||||||
|
### 4.5 播放器
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-040 | 支持模式切换:整体讲解 / 逐段精读 | P0 | 切换后列表与播放单位正确 |
|
||||||
|
| FR-041 | 播放控制:播放 / 暂停 / 上一单位 / 下一单位 / 1x·1.5x·2x | P0 | 状态与 `<audio>` 同步 |
|
||||||
|
| FR-042 | 单位列表展示进度:已听 / 未听;播放中自动滚动 | P0 | 断点续播恢复正确单位 |
|
||||||
|
| FR-043 | 整体模式:文字面板展示 beat 文本 + refs 来源段落高亮 | P0 | 高亮与当前 beat 一致 |
|
||||||
|
| FR-044 | 逐段模式:左侧原文 + 右侧三层讲解,高亮跟随音频 | P0 | 至少句子级跟随 |
|
||||||
|
| FR-045 | 「看原文」跳转 PDF 对应页 | P0 | 页码与段落 page 字段一致 |
|
||||||
|
| FR-046 | 点击列表任意行跳播 | P0 | Seek 到该单位起始 |
|
||||||
|
| FR-047 | 段落支持「重点标记」★ | P0 | 标记写入进度并进入报告 |
|
||||||
|
|
||||||
|
### 4.6 问答
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-050 | 任意时刻文字提问,锚定类型:beat / 段落 / 句子 / 全局 | P0 | 上下文含锚点原文与讲解 |
|
||||||
|
| FR-051 | 暂停时提问入口视觉强化 | P1 | 暂停态输入框高亮 |
|
||||||
|
| FR-052 | 选中句子触发「解释这句」(逐句讲解) | P0 | 输出:翻译 + 句法拆解 + 上下文作用 |
|
||||||
|
| FR-053 | 回答基于 RAG:当前单位 + 全文摘要 + 检索相关段 | P0 | 长论文不整篇塞入 Prompt |
|
||||||
|
| FR-054 | 回答文字流式显示,随后 TTS 播放 | P0 | 首 token ≤ 3 s(网络正常) |
|
||||||
|
| FR-055 | 所有 Q&A 写入本地记录并纳入伴读报告 | P0 | 可按段落分组展示 |
|
||||||
|
| FR-056 | 不支持语音输入与 barge-in(MVP) | — | 仅文字输入 |
|
||||||
|
|
||||||
|
### 4.7 伴读报告与参考文献卡片
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-060 | 手动或条件触发「生成报告」 | P0 | 不覆盖已有报告时需确认(或版本策略见架构) |
|
||||||
|
| FR-061 | 报告结构:论文速览 / 收听情况 / 问答记录 / 重点标记 / 参考文献卡片 | P0 | Markdown 结构固定 |
|
||||||
|
| FR-062 | 参考文献卡片:中文速览 + 推荐引用句 + BibTeX | P0 | 可直接粘贴进 LaTeX 写作流 |
|
||||||
|
| FR-063 | 支持导出 .md 文件 | P1 | 与 Note 内容一致 |
|
||||||
|
|
||||||
|
### 4.8 设置
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-070 | 配置 LLM 提供商、Endpoint、API Key | P0 | 支持 OpenAI 兼容 API + Ollama |
|
||||||
|
| FR-071 | 配置 TTS 提供商与 Key | P0 | 至少支持一种中文 TTS |
|
||||||
|
| FR-072 | 教师风格 Prompt 预设(如:简洁 / 详细) | P1 | 变更后对**新生成**内容生效 |
|
||||||
|
| FR-073 | 明示数据流向:哪些文本会发往外部 API | P0 | 设置页固定文案 |
|
||||||
|
| FR-074 | API Key 安全存储(Zotero 偏好或 OS Keychain) | P0 | 不以明文写入日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 非功能需求
|
||||||
|
|
||||||
|
| ID | 类别 | 需求描述 | 指标 |
|
||||||
|
|----|------|----------|------|
|
||||||
|
| NFR-001 | 性能 | 8 页论文本地解析 | ≤ 3 min(CPU) |
|
||||||
|
| NFR-002 | 性能 | 缓存命中后打开伴读页 | ≤ 2 s 可播放 |
|
||||||
|
| NFR-003 | 性能 | 问答首字延迟 | ≤ 3 s |
|
||||||
|
| NFR-004 | 成本 | 单篇 8 页论文首次备课总 API 成本 | ≤ ¥10 |
|
||||||
|
| NFR-005 | 可用性 | 二次打开 / 换模式重听 | 零 LLM/TTS 调用 |
|
||||||
|
| NFR-006 | 隐私 | PDF 文件不上传;仅段落文本按请求发送 | Local-first |
|
||||||
|
| NFR-007 | 离线 | 已缓存 Lecture + mp3 可离线播放 | 除问答外不依赖网络 |
|
||||||
|
| NFR-008 | 可靠性 | 备课/Task 中断后可恢复 | 状态机支持断点续跑 |
|
||||||
|
| NFR-009 | 兼容性 | Zotero 7.x 指定 minor 版本范围 | 文档 pin 版本 |
|
||||||
|
| NFR-010 | 可维护性 | UI 层与 Zotero API 适配层分离 | 代码模块边界清晰 |
|
||||||
|
| NFR-011 | 无障碍 | 播放器键盘可操作(播放/暂停/跳段) | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 界面需求摘要
|
||||||
|
|
||||||
|
伴读页布局见 [PLAN.md §3.6](./PLAN.md);关键交互原则:
|
||||||
|
|
||||||
|
- **读者发起,系统不打扰**:播完不弹「有疑问吗」
|
||||||
|
- **原文始终可见**:讲解与原文并排,降低幻觉信任成本
|
||||||
|
- **进度可感知**:首次备课、增量生成、TTS 合成均有进度反馈
|
||||||
|
- **错误可恢复**:解析失败、API 失败提供重试与降级路径
|
||||||
|
|
||||||
|
### 6.1 状态展示
|
||||||
|
|
||||||
|
| 状态 | 用户可见文案示例 |
|
||||||
|
|------|------------------|
|
||||||
|
| 未备课 | 「点击开始备课」 |
|
||||||
|
| 解析中 | 「正在读取论文… 45%」 |
|
||||||
|
| 备课中(可部分播放) | 「整体讲解已就绪,逐段讲解生成中 12/58」 |
|
||||||
|
| 已备课 | 「老师已备课 ✓」 |
|
||||||
|
| 播放中 | 当前单位高亮 + 进度条 |
|
||||||
|
| 离线可听 | 「已缓存,离线可播放」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 内容质量需求(Prompt / 讲解)
|
||||||
|
|
||||||
|
| ID | 需求 |
|
||||||
|
|----|------|
|
||||||
|
| CQ-001 | 讲解必须 grounding 到段落原文,温度偏低 |
|
||||||
|
| CQ-002 | 公式转口语,不朗读 LaTeX 符号 |
|
||||||
|
| CQ-003 | 专有名词:英文保留 + 中文解释 |
|
||||||
|
| CQ-004 | beat 类型标签枚举:问题引入 / 核心方法 / 关键实验 / 结论局限 / 延伸思考 |
|
||||||
|
| CQ-005 | 问答需覆盖:翻译 / 理解 / 逻辑 / 关联 / 元认知五类问题 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. MVP 验收清单
|
||||||
|
|
||||||
|
- [ ] 在 Zotero 7 安装插件,Item pane 出现伴读页
|
||||||
|
- [ ] 对 1 篇 8 页英文 PDF 完成:解析 → 备课 → 整体讲解播放
|
||||||
|
- [ ] 切换到逐段精读并完成至少 1 个章节播放
|
||||||
|
- [ ] 提问 3 次(含 1 次全局 + 1 次逐句),文字与语音均有
|
||||||
|
- [ ] 标记 2 个重点段落
|
||||||
|
- [ ] 生成伴读报告至 Zotero 子笔记,含 BibTeX
|
||||||
|
- [ ] 关闭 Zotero 再打开,缓存命中、断点续播
|
||||||
|
- [ ] airplane 模式(或断网)下重听已缓存内容
|
||||||
|
- [ ] Dogfooding 累计 10 篇,记录指标表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 版本规划(需求视角)
|
||||||
|
|
||||||
|
| 版本 | 核心交付 |
|
||||||
|
|------|----------|
|
||||||
|
| Phase 0 | 三项 POC 通过:Zotero pane、MinerU 解析、TTS timestamps |
|
||||||
|
| MVP(M1–M4) | 本文 §2.1 全部 P0 需求 |
|
||||||
|
| v1.1 | ASR、barge-in、难点句清单、PDF 内高亮、理解度评估 |
|
||||||
|
| v2 | 移动端、云同步、检验模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| BanDu / 伴读 | 产品品牌名;英文 BanDu 为「伴读」音译,Zotero 插件全名 BanDu for Zotero |
|
||||||
|
| Lecture | 一次备课的完整产物:摘要 + beats + 逐段讲解 + 元数据 |
|
||||||
|
| Beat | 整体讲解模式下的播放单位,30 s–2 min,含 refs[] |
|
||||||
|
| 三层讲解 | 讲了什么 / 怎么理解 / 全文定位 |
|
||||||
|
| 增量备课 | 摘要与 beats 优先生成,逐段与 TTS 后台继续 |
|
||||||
|
| refs[] | Beat 引用的来源段落 ID 列表 |
|
||||||
|
| 伴读报告 | 听后汇总写入 Zotero 的 Markdown 笔记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 待决事项
|
||||||
|
|
||||||
|
| 事项 | 负责人 | 截止 |
|
||||||
|
|------|--------|------|
|
||||||
|
| TTS 提供商最终选型 | 技术 | Phase 0 |
|
||||||
|
| 报告生成:覆盖 vs 追加策略 | 产品 | M3 前 |
|
||||||
|
| 极长论文逐段生成策略(全量 vs 按章 lazy) | 产品 + 技术 | M1 前 |
|
||||||
|
| Better BibTeX 缺失时的 BibTeX 生成规则 | 技术 | M3 前 |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 文档索引
|
||||||
|
|
||||||
|
**BanDu(伴读)** — Zotero AI 论文伴读插件项目文档。
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 插件全名 | BanDu for Zotero |
|
||||||
|
|
||||||
|
| 文档 | 说明 | 读者 |
|
||||||
|
|------|------|------|
|
||||||
|
| [PLAN.md](./PLAN.md) | 产品策划书:背景、竞品、设计决策、路线图 | 产品 / 全员 |
|
||||||
|
| [PRD.md](./PRD.md) | 产品需求规格说明书:功能需求、非功能需求、验收标准 | 产品 / 开发 / 测试 |
|
||||||
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 软件架构设计:分层、数据模型、流水线、状态机 | 开发 / 架构 |
|
||||||
|
|
||||||
|
## 阅读顺序建议
|
||||||
|
|
||||||
|
1. **PLAN.md** — 理解产品定位与为什么做
|
||||||
|
2. **PRD.md** — 明确做什么、验收标准
|
||||||
|
3. **ARCHITECTURE.md** — 明确怎么做、模块与数据如何组织
|
||||||
|
|
||||||
|
## 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PLAN.md(策划)
|
||||||
|
├── PRD.md(需求:What)
|
||||||
|
└── ARCHITECTURE.md(架构:How)
|
||||||
|
```
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "zotero-plugin serve",
|
"start": "zotero-plugin serve",
|
||||||
"build": "zotero-plugin build && tsc --noEmit",
|
"build": "zotero-plugin build && tsc --noEmit",
|
||||||
|
"clean": "rm -rf .scaffold/build",
|
||||||
|
"rebuild": "npm run clean && npm run build",
|
||||||
"lint:check": "prettier --check . && eslint .",
|
"lint:check": "prettier --check . && eslint .",
|
||||||
"lint:fix": "prettier --write . && eslint . --fix",
|
"lint:fix": "prettier --write . && eslint . --fix",
|
||||||
"release": "zotero-plugin release",
|
"release": "zotero-plugin release",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# ChatPapers
|
||||||
|
|
||||||
|
**Language / 语言**: [中文](readme.md) | English
|
||||||
|
|
||||||
|
An AI paper reading and summarization plugin for **Zotero 9**: chat with PDFs, generate summaries, compare multiple papers, listen to AI voice lectures, and save results as Zotero notes—all inside Zotero.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Paper chat (ChatPapers)
|
||||||
|
|
||||||
|
- **ChatPapers** panel in the PDF / item sidebar
|
||||||
|
- One-click summary, add selection to context, streaming replies, stop generation
|
||||||
|
- Save replies as Zotero child notes
|
||||||
|
- Provider presets: **Ollama / LM Studio / OpenRouter / OpenAI / DeepSeek / SiliconFlow / Custom**
|
||||||
|
- Sidebar header: **Open in separate window**, **Adaptive height** (consistent with other Zotero plugins)
|
||||||
|
|
||||||
|
### Multi-paper comparison
|
||||||
|
|
||||||
|
- Start **Compare papers** from the main-window item context menu
|
||||||
|
- Add up to 8 papers for **Compare**, **Summarize each**, **Common themes**, and **Topic review**
|
||||||
|
|
||||||
|
### Voice lecture
|
||||||
|
|
||||||
|
- **Voice lecture** sidebar: one-click **Prepare** → listen to an AI walkthrough of the full paper
|
||||||
|
- **Overview**: segmented summary, play beat by beat
|
||||||
|
- **Paragraph reading**: original text first, then translation/explanation; switch **By paragraph** / **By sentence**
|
||||||
|
- **Highlight sync** in the PDF reader while playing (including multi-line selections)
|
||||||
|
- Typed Q&A with spoken answers; save lecture notes as Zotero child notes
|
||||||
|
- Configurable local TTS (e.g. Piper) and cloud TTS; optional MinerU path in preferences for structured PDF parsing
|
||||||
|
|
||||||
|
### Selection explain & read aloud
|
||||||
|
|
||||||
|
- After selecting text in a PDF, use **Explain** from the selection popup or sidebar
|
||||||
|
- AI generates syntax/terminology and context explanations, then reads original + translated/explained audio
|
||||||
|
- Reuses prepared lecture cache when the selection matches an already prepared sentence
|
||||||
|
- Sidebar also supports **Open in separate window** and **Adaptive height**
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| [Requirements (zh-CN)](docs/requirements.md) | Product scope and acceptance criteria |
|
||||||
|
| [Development (zh-CN)](docs/development.md) | Architecture and developer guide |
|
||||||
|
| [Architecture & roadmap (zh-CN)](docs/newidea/) | Voice lecture and future capabilities |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # outputs .scaffold/build/*.xpi
|
||||||
|
npm run start # requires Zotero 9 locally; configure .env (see .env.example)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Run `npm run build`
|
||||||
|
2. In Zotero: Tools → Add-ons → Install Add-on From File
|
||||||
|
3. Select `.scaffold/build/chatpapers.xpi` (exact filename may vary)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Paper chat
|
||||||
|
|
||||||
|
1. Preferences → ChatPapers: choose a Provider (e.g. Ollama) and set the model
|
||||||
|
2. Open a PDF or select an item, then open **ChatPapers** in the item pane sidebar
|
||||||
|
3. Ask questions or summarize → save replies as notes
|
||||||
|
|
||||||
|
### Multi-paper comparison
|
||||||
|
|
||||||
|
1. Select 2 or more items in the Zotero main window
|
||||||
|
2. Right-click → **ChatPapers: Compare papers**
|
||||||
|
3. Add papers, enter a topic, and use compare or review actions
|
||||||
|
|
||||||
|
### Voice lecture
|
||||||
|
|
||||||
|
1. Preferences → ChatPapers → **Voice lecture**: configure LLM and TTS; set MinerU path if you need structured PDF parsing
|
||||||
|
2. Open an item with a PDF attachment, then open **Voice lecture** in the sidebar
|
||||||
|
3. Click **Start preparation**, then choose overview or paragraph/sentence reading
|
||||||
|
4. The PDF highlights the current passage while audio plays
|
||||||
|
|
||||||
|
### Selection explain & read aloud
|
||||||
|
|
||||||
|
1. Select a sentence or paragraph in the PDF reader
|
||||||
|
2. Click **Explain** in the selection popup, or **Explain & read selection** in the **Voice lecture** sidebar
|
||||||
|
3. Wait for generation, then audio plays automatically; view details and replay from the sidebar
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Unreleased (main, after v0.2.1)
|
||||||
|
|
||||||
|
- **Voice lecture**: preparation, overview, paragraph/sentence reading, TTS playback, note export
|
||||||
|
- **PDF highlight sync**: highlight matching text while playing; fix multi-line selections only highlighting one line
|
||||||
|
- **Selection explain & read aloud**: PDF selection popup + sidebar; reuse prepared sentence cache when matched
|
||||||
|
- **Playback exclusivity**: fix overlapping audio for multi-sentence selections; stop audio when the pane is closed
|
||||||
|
- **Sidebar enhancements**: separate window and adaptive height for chat and voice lecture panes
|
||||||
|
- **Branding**: updated plugin logo
|
||||||
|
|
||||||
|
### v0.2.1
|
||||||
|
|
||||||
|
- Improved release workflow and installable `.xpi` packaging
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- **Multi-paper comparison**: compare, summarize each, common themes, topic review (up to 8 papers)
|
||||||
|
|
||||||
|
### v0.1.0 (MVP)
|
||||||
|
|
||||||
|
- PDF / item sidebar chat, one-click summary, selection context, streaming replies
|
||||||
|
- Multiple Provider presets and preferences
|
||||||
|
- Save AI replies as Zotero child notes
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0-or-later (based on [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template))
|
||||||
@@ -1,13 +1,39 @@
|
|||||||
# ChatPapers
|
# ChatPapers
|
||||||
|
|
||||||
面向 **Zotero 9** 的 AI 论文阅读与总结插件:在 Zotero 内对 PDF 文献对话、摘要,并把结果保存为笔记。
|
**语言 / Language**: 中文 | [English](readme.en.md)
|
||||||
|
|
||||||
## 功能(MVP)
|
面向 **Zotero 9** 的 AI 论文阅读与总结插件:在 Zotero 内对 PDF 文献对话、摘要、多篇对照,并提供 AI 语音伴读,把结果保存为笔记。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
### 论文对话(ChatPapers)
|
||||||
|
|
||||||
- PDF / 条目侧栏 **ChatPapers** 对话面板
|
- PDF / 条目侧栏 **ChatPapers** 对话面板
|
||||||
- 一键总结、添加选区、流式回复、停止生成
|
- 一键总结、添加选区、流式回复、停止生成
|
||||||
- 保存为 Zotero 子笔记
|
- 保存为 Zotero 子笔记
|
||||||
- Provider 预设:**Ollama / LM Studio / OpenRouter / OpenAI / DeepSeek / 硅基流动 / 自定义**
|
- Provider 预设:**Ollama / LM Studio / OpenRouter / OpenAI / DeepSeek / 硅基流动 / 自定义**
|
||||||
|
- 侧栏标题栏:**在独立窗口中打开**、**自适应高度**(与 Zotero 其他插件一致)
|
||||||
|
|
||||||
|
### 多篇文献对照
|
||||||
|
|
||||||
|
- 从主窗口条目菜单发起 **多篇对照** 对话
|
||||||
|
- 支持添加最多 8 篇文献,进行 **对比差异**、**各自摘要**、**共同主题**、**主题综述**
|
||||||
|
|
||||||
|
### 语音伴读(Voice lecture)
|
||||||
|
|
||||||
|
- 条目侧栏 **语音伴读** 面板:一键 **备课** → 听 AI 讲解整篇论文
|
||||||
|
- **整体讲解**:分段概览,逐段播放
|
||||||
|
- **逐段精读**:先读原文、再听译文/讲解;可切换 **按段** / **按句** 粒度
|
||||||
|
- 播放时在 PDF 阅读器中 **高亮对应原文**(支持跨行选区)
|
||||||
|
- 文字提问,获得语音回答;可将伴读笔记写入 Zotero 子笔记
|
||||||
|
- 本地 TTS(如 Piper)与云端 TTS 可配置;偏好设置中可配置 MinerU 路径以启用 PDF 结构化解析
|
||||||
|
|
||||||
|
### 划句 / 划段 AI 伴读
|
||||||
|
|
||||||
|
- 在 PDF 中选中文本后,选区弹窗或侧栏可 **解释朗读**
|
||||||
|
- AI 生成句法/术语与上下文讲解,并朗读原文与中文讲解
|
||||||
|
- 若选区与已备课内容匹配,优先复用缓存,减少重复调用
|
||||||
|
- 侧栏同样提供 **在独立窗口中打开**、**自适应高度**
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
@@ -15,6 +41,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [需求文档](docs/requirements.md) | 产品范围与验收 |
|
| [需求文档](docs/requirements.md) | 产品范围与验收 |
|
||||||
| [开发文档](docs/development.md) | 技术架构与开发说明 |
|
| [开发文档](docs/development.md) | 技术架构与开发说明 |
|
||||||
|
| [架构与规划](docs/newidea/) | 语音伴读等后续能力设计 |
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -32,10 +59,56 @@ npm run start # 需本机已安装 Zotero 9,并配置 .env(见 .env.examp
|
|||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
|
### 论文对话
|
||||||
|
|
||||||
1. 偏好设置 → ChatPapers:选择 Provider(如 Ollama)并填写模型
|
1. 偏好设置 → ChatPapers:选择 Provider(如 Ollama)并填写模型
|
||||||
2. 打开 PDF 或选中条目,在条目面板侧栏进入 **ChatPapers**
|
2. 打开 PDF 或选中条目,在条目面板侧栏进入 **ChatPapers**
|
||||||
3. 提问 / 总结 → 可将回复「保存为笔记」
|
3. 提问 / 总结 → 可将回复「保存为笔记」
|
||||||
|
|
||||||
|
### 多篇对照
|
||||||
|
|
||||||
|
1. 在 Zotero 主窗口选中 2 篇及以上文献
|
||||||
|
2. 右键 → **ChatPapers:多篇对照**
|
||||||
|
3. 在对话框中添加文献、输入主题,使用对比或综述功能
|
||||||
|
|
||||||
|
### 语音伴读
|
||||||
|
|
||||||
|
1. 偏好设置 → ChatPapers → **语音伴读**:配置 LLM、TTS 引擎;如需结构化 PDF 解析,填写 MinerU 路径
|
||||||
|
2. 打开带 PDF 的条目,侧栏进入 **语音伴读**
|
||||||
|
3. 点击 **开始备课**,完成后选择整体讲解或逐段/逐句精读播放
|
||||||
|
4. 播放过程中 PDF 会同步高亮当前段落
|
||||||
|
|
||||||
|
### 划句解释朗读
|
||||||
|
|
||||||
|
1. 在 PDF 阅读器中选中一句或一段文字
|
||||||
|
2. 在选区弹窗点击 **解释朗读**,或在 **语音伴读** 侧栏点击 **解释朗读选区**
|
||||||
|
3. 等待生成完成后自动播放;可在侧栏查看讲解详情并重播
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### Unreleased(main,v0.2.1 之后)
|
||||||
|
|
||||||
|
- **语音伴读**:备课、整体讲解、逐段/逐句精读、TTS 播放与笔记保存
|
||||||
|
- **PDF 同步高亮**:伴读播放时在阅读器中高亮对应原文,修复跨行选区只高亮一行的问题
|
||||||
|
- **划句/划段 AI 伴读**:PDF 选区解释朗读;侧栏与选区弹窗双入口;匹配已备课句子时复用缓存
|
||||||
|
- **播放互斥**:修复多句/多段选区解释时多路语音重叠播放;关闭面板时停止音频
|
||||||
|
- **侧栏增强**:对话与语音伴读面板支持独立窗口、自适应高度
|
||||||
|
- **品牌**:更新插件 Logo
|
||||||
|
|
||||||
|
### v0.2.1
|
||||||
|
|
||||||
|
- 发布流程与可安装 `.xpi` 包优化
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- **多篇文献对照**:对比差异、各自摘要、共同主题、主题综述(最多 8 篇)
|
||||||
|
|
||||||
|
### v0.1.0(MVP)
|
||||||
|
|
||||||
|
- PDF / 条目侧栏对话、一键总结、选区提问、流式回复
|
||||||
|
- 多 Provider 预设与偏好设置
|
||||||
|
- 保存 AI 回复为 Zotero 子笔记
|
||||||
|
|
||||||
## 许可
|
## 许可
|
||||||
|
|
||||||
AGPL-3.0-or-later(基于 [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template))
|
AGPL-3.0-or-later(基于 [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template))
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* zotero-plugin-toolkit's `_importESModule` still prefers ChromeUtils.import
|
||||||
|
* when that property exists. On Zotero 9 / Firefox 128+, `import` remains as a
|
||||||
|
* stub that throws "ChromeUtils.import() has been removed".
|
||||||
|
*/
|
||||||
|
export function fixToolkitImportESModule() {
|
||||||
|
return {
|
||||||
|
name: "chatpapers-fix-toolkit-importesmodule",
|
||||||
|
setup(build) {
|
||||||
|
build.onLoad(
|
||||||
|
{ filter: /zotero-plugin-toolkit[\\/]dist[\\/]index\.js$/ },
|
||||||
|
async (args) => {
|
||||||
|
const text = await fs.readFile(args.path, "utf8");
|
||||||
|
const next = text.replace(
|
||||||
|
/function _importESModule\(path\) \{[\s\S]*?\n\}/,
|
||||||
|
`function _importESModule(path) {
|
||||||
|
\tif (typeof ChromeUtils.importESModule === "function") {
|
||||||
|
\t\ttry {
|
||||||
|
\t\t\treturn ChromeUtils.importESModule(path, { global: "contextual" });
|
||||||
|
\t\t} catch (e) {
|
||||||
|
\t\t\treturn ChromeUtils.importESModule(path);
|
||||||
|
\t\t}
|
||||||
|
\t}
|
||||||
|
\tif (path.endsWith(".sys.mjs")) path = path.replace(/\\.sys\\.mjs$/, ".jsm");
|
||||||
|
\treturn ChromeUtils.import(path);
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
if (next === text) {
|
||||||
|
console.warn(
|
||||||
|
"[chatpapers] toolkit _importESModule patch did not match; check toolkit version",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { contents: next, loader: "js" };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { initLocale } from "./utils/locale";
|
import { initLocale } from "./utils/locale";
|
||||||
import { createZToolkit } from "./utils/ztoolkit";
|
import { createZToolkit } from "./utils/ztoolkit";
|
||||||
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
|
||||||
|
import { registerSelectionExplainPopup } from "./modules/lecture/ui/registerSelectionExplainPopup";
|
||||||
|
import { registerChatPane, registerPrefs, ensureChatPapersStyles } from "./modules/ui/readerPane";
|
||||||
import { registerPrefsScripts } from "./modules/ui/prefs";
|
import { registerPrefsScripts } from "./modules/ui/prefs";
|
||||||
import { registerItemMenus } from "./modules/ui/itemMenu";
|
import { registerItemMenus } from "./modules/ui/itemMenu";
|
||||||
import { registerReaderSelectionHook } from "./modules/pdf/selection";
|
import { registerReaderSelectionHook } from "./modules/pdf/selection";
|
||||||
@@ -15,7 +17,9 @@ async function onStartup() {
|
|||||||
initLocale();
|
initLocale();
|
||||||
registerPrefs();
|
registerPrefs();
|
||||||
registerChatPane();
|
registerChatPane();
|
||||||
|
registerLecturePane();
|
||||||
registerReaderSelectionHook();
|
registerReaderSelectionHook();
|
||||||
|
registerSelectionExplainPopup();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
||||||
@@ -26,9 +30,13 @@ async function onStartup() {
|
|||||||
|
|
||||||
async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
|
async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
|
||||||
addon.data.ztoolkit = createZToolkit();
|
addon.data.ztoolkit = createZToolkit();
|
||||||
|
ensureChatPapersStyles(win.document);
|
||||||
win.MozXULElement.insertFTLIfNeeded(
|
win.MozXULElement.insertFTLIfNeeded(
|
||||||
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
||||||
);
|
);
|
||||||
|
win.MozXULElement.insertFTLIfNeeded(
|
||||||
|
`${addon.data.config.addonRef}-addon.ftl`,
|
||||||
|
);
|
||||||
registerItemMenus();
|
registerItemMenus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { SummaryJson } from "../domain/types";
|
||||||
|
import { isPrimarilyEnglish } from "../infrastructure/llm/paragraphLlm";
|
||||||
|
import { generateSentenceExplanation } from "../infrastructure/llm/sentenceLlm";
|
||||||
|
import { ensureAudioDir } from "../infrastructure/storage/audioStorage";
|
||||||
|
import {
|
||||||
|
loadLectureData,
|
||||||
|
mergeLectureData,
|
||||||
|
type AdHocSelectionExplain,
|
||||||
|
type StoredParagraph,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
import { audioFilePath } from "../infrastructure/storage/paths";
|
||||||
|
import { synthesizeSpeech } from "../infrastructure/tts/client";
|
||||||
|
import { TtsError } from "../infrastructure/tts/types";
|
||||||
|
import {
|
||||||
|
getRegularItem,
|
||||||
|
resolvePaperAttachment,
|
||||||
|
} from "../infrastructure/zotero/adapter";
|
||||||
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import type { TextChunk } from "../infrastructure/pdf/chunker";
|
||||||
|
import {
|
||||||
|
findMatchingPreparedSentence,
|
||||||
|
normalizeSelectionText,
|
||||||
|
} from "./matchPreparedSentence";
|
||||||
|
|
||||||
|
const TTS_ZH_MAX = 280;
|
||||||
|
const TTS_EN_MAX = 600;
|
||||||
|
|
||||||
|
export interface SelectionExplainResult {
|
||||||
|
original: string;
|
||||||
|
translation: string;
|
||||||
|
syntaxNote: string;
|
||||||
|
contextRole: string;
|
||||||
|
audioPathEn?: string;
|
||||||
|
audioPathZh: string;
|
||||||
|
page?: number;
|
||||||
|
fromCache: boolean;
|
||||||
|
fromPrepared: boolean;
|
||||||
|
matched?: {
|
||||||
|
paragraphId: string;
|
||||||
|
sentenceId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionUnitId(text: string): string {
|
||||||
|
const norm = normalizeSelectionText(text);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < norm.length; i++) {
|
||||||
|
hash = (hash * 31 + norm.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return `adhoc-${Math.abs(hash).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSurroundingContext(
|
||||||
|
chunks: TextChunk[] | undefined,
|
||||||
|
selectedText: string,
|
||||||
|
): string {
|
||||||
|
if (!chunks?.length || !selectedText.trim()) return "";
|
||||||
|
const needle = normalizeSelectionText(selectedText).slice(0, 48);
|
||||||
|
if (!needle) return "";
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const hay = normalizeSelectionText(chunk.text);
|
||||||
|
const idx = hay.indexOf(needle);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const start = Math.max(0, idx - 220);
|
||||||
|
const end = Math.min(chunk.text.length, idx + selectedText.length + 220);
|
||||||
|
return chunk.text.slice(start, end);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAdHocCache(
|
||||||
|
entries: AdHocSelectionExplain[] | undefined,
|
||||||
|
selectedText: string,
|
||||||
|
): AdHocSelectionExplain | undefined {
|
||||||
|
if (!entries?.length) return undefined;
|
||||||
|
const norm = normalizeSelectionText(selectedText);
|
||||||
|
return entries.find(
|
||||||
|
(entry) => normalizeSelectionText(entry.original) === norm && entry.audioPathZh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparedToResult(
|
||||||
|
match: NonNullable<ReturnType<typeof findMatchingPreparedSentence>>,
|
||||||
|
): SelectionExplainResult {
|
||||||
|
const { paragraph, sentence } = match;
|
||||||
|
return {
|
||||||
|
original: sentence.text,
|
||||||
|
translation: sentence.translation,
|
||||||
|
syntaxNote: "",
|
||||||
|
contextRole: paragraph.roleInPaper || paragraph.contextLink || "",
|
||||||
|
audioPathEn:
|
||||||
|
sentence.ttsEnStatus === "ready" ? sentence.audioPathEn : undefined,
|
||||||
|
audioPathZh: sentence.audioPathZh!,
|
||||||
|
page: sentence.page ?? paragraph.page,
|
||||||
|
fromCache: true,
|
||||||
|
fromPrepared: true,
|
||||||
|
matched: {
|
||||||
|
paragraphId: paragraph.id,
|
||||||
|
sentenceId: sentence.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adHocToResult(entry: AdHocSelectionExplain): SelectionExplainResult {
|
||||||
|
return {
|
||||||
|
original: entry.original,
|
||||||
|
translation: entry.translation,
|
||||||
|
syntaxNote: entry.syntaxNote,
|
||||||
|
contextRole: entry.contextRole,
|
||||||
|
audioPathEn: entry.audioPathEn,
|
||||||
|
audioPathZh: entry.audioPathZh!,
|
||||||
|
page: entry.page,
|
||||||
|
fromCache: true,
|
||||||
|
fromPrepared: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthesizeSelectionAudio(options: {
|
||||||
|
cacheKey: string;
|
||||||
|
unitId: string;
|
||||||
|
original: string;
|
||||||
|
translation: string;
|
||||||
|
}): Promise<{ audioPathEn?: string; audioPathZh: string }> {
|
||||||
|
await ensureAudioDir(options.cacheKey);
|
||||||
|
const en = isPrimarilyEnglish(options.original);
|
||||||
|
let audioPathEn: string | undefined;
|
||||||
|
|
||||||
|
if (en) {
|
||||||
|
const outPath = audioFilePath(options.cacheKey, `${options.unitId}-en`, "wav");
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: options.original.slice(0, TTS_EN_MAX),
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "en",
|
||||||
|
});
|
||||||
|
audioPathEn = result.audioPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zhPath = audioFilePath(options.cacheKey, `${options.unitId}-zh`, "wav");
|
||||||
|
const zhResult = await synthesizeSpeech({
|
||||||
|
text: options.translation.slice(0, TTS_ZH_MAX),
|
||||||
|
outputPath: zhPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { audioPathEn, audioPathZh: zhResult.audioPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function explainSelectionLecture(options: {
|
||||||
|
item: Zotero.Item;
|
||||||
|
selectedText: string;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
}): Promise<SelectionExplainResult> {
|
||||||
|
const selectedText = options.selectedText.trim();
|
||||||
|
if (!selectedText) {
|
||||||
|
throw new Error("请先选中 PDF 中的文本。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolvePaperAttachment(options.item);
|
||||||
|
const data = await loadLectureData(ctx.cacheKey);
|
||||||
|
const paragraphs = data?.paragraphs;
|
||||||
|
|
||||||
|
const prepared = findMatchingPreparedSentence(paragraphs, selectedText);
|
||||||
|
if (prepared?.sentence.audioPathZh) {
|
||||||
|
return preparedToResult(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = findAdHocCache(data?.adHocSelections, selectedText);
|
||||||
|
if (cached?.audioPathZh) {
|
||||||
|
return adHocToResult(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onProgress?.("正在生成单句讲解…");
|
||||||
|
|
||||||
|
const parent = getRegularItem(options.item);
|
||||||
|
const title = String(parent.getField("title") || "");
|
||||||
|
const summary = data?.summary as SummaryJson | undefined;
|
||||||
|
const surroundingContext = findSurroundingContext(data?.chunks, selectedText);
|
||||||
|
|
||||||
|
const llm = await generateSentenceExplanation({
|
||||||
|
paperTitle: title,
|
||||||
|
summary,
|
||||||
|
selectedText,
|
||||||
|
surroundingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.onProgress?.("正在合成语音…");
|
||||||
|
|
||||||
|
const unitId = selectionUnitId(selectedText);
|
||||||
|
let audioPathEn: string | undefined;
|
||||||
|
let audioPathZh: string;
|
||||||
|
try {
|
||||||
|
const audio = await synthesizeSelectionAudio({
|
||||||
|
cacheKey: ctx.cacheKey,
|
||||||
|
unitId,
|
||||||
|
original: selectedText,
|
||||||
|
translation: llm.translation,
|
||||||
|
});
|
||||||
|
audioPathEn = audio.audioPathEn;
|
||||||
|
audioPathZh = audio.audioPathZh;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TtsError) throw e;
|
||||||
|
throw new Error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: AdHocSelectionExplain = {
|
||||||
|
id: unitId,
|
||||||
|
original: selectedText,
|
||||||
|
translation: llm.translation,
|
||||||
|
syntaxNote: llm.syntaxNote,
|
||||||
|
contextRole: llm.contextRole,
|
||||||
|
audioPathEn,
|
||||||
|
audioPathZh,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = data?.adHocSelections ?? [];
|
||||||
|
const nextEntries = [
|
||||||
|
entry,
|
||||||
|
...existing.filter(
|
||||||
|
(e) => normalizeSelectionText(e.original) !== normalizeSelectionText(selectedText),
|
||||||
|
),
|
||||||
|
].slice(0, 40);
|
||||||
|
|
||||||
|
await mergeLectureData(ctx.cacheKey, paperIdFromCacheKey(ctx.cacheKey), {
|
||||||
|
adHocSelections: nextEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: selectedText,
|
||||||
|
translation: llm.translation,
|
||||||
|
syntaxNote: llm.syntaxNote,
|
||||||
|
contextRole: llm.contextRole,
|
||||||
|
audioPathEn,
|
||||||
|
audioPathZh,
|
||||||
|
fromCache: false,
|
||||||
|
fromPrepared: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPreparedMatchForSelection(
|
||||||
|
paragraphs: StoredParagraph[] | undefined,
|
||||||
|
selectedText: string,
|
||||||
|
) {
|
||||||
|
return findMatchingPreparedSentence(paragraphs, selectedText);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
|
||||||
|
import type { LectureData } from "../infrastructure/storage/lectureStore";
|
||||||
|
import { createChildNote } from "../../zotero/notes";
|
||||||
|
import { markdownToNoteHtml } from "../../../utils/markdown";
|
||||||
|
|
||||||
|
const NOTE_TITLE_PREFIX = "ChatPapers 伴读笔记:";
|
||||||
|
|
||||||
|
export function buildLectureNoteMarkdown(
|
||||||
|
data: LectureData,
|
||||||
|
paperTitle: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`> 由 ChatPapers 语音伴读自动生成。`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (data.summary) {
|
||||||
|
lines.push("## 全文摘要", "", formatSummaryForDisplay(data.summary), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.paragraphs?.length) {
|
||||||
|
lines.push("## 逐段精读(原文 + 译文 + 上下文)", "");
|
||||||
|
for (const p of data.paragraphs) {
|
||||||
|
if (p.genStatus !== "ready") continue;
|
||||||
|
lines.push(
|
||||||
|
`### 段落 ${p.orderIndex + 1}`,
|
||||||
|
"",
|
||||||
|
`**在全文中的作用**:${p.roleInPaper || "—"}`,
|
||||||
|
"",
|
||||||
|
"**原文**",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
p.originalText,
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"**译文 / 讲解**",
|
||||||
|
"",
|
||||||
|
p.translation,
|
||||||
|
"",
|
||||||
|
`**上下文衔接**:${p.contextLink || "—"}`,
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.beats?.length) {
|
||||||
|
lines.push("## 整体讲解(beats)", "");
|
||||||
|
for (const b of data.beats) {
|
||||||
|
lines.push(`### ${b.title || b.id}`, "", b.script, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
`---`,
|
||||||
|
`*论文:${paperTitle} · 更新于 ${new Date(data.updatedAt).toLocaleString()}*`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExistingLectureNote(
|
||||||
|
parentItem: Zotero.Item,
|
||||||
|
noteTitle: string,
|
||||||
|
): Zotero.Item | undefined {
|
||||||
|
try {
|
||||||
|
const noteIds = parentItem.getNotes();
|
||||||
|
for (const id of noteIds) {
|
||||||
|
const note = Zotero.Items.get(id) as Zotero.Item | false;
|
||||||
|
if (!note) continue;
|
||||||
|
const html = String(note.getNote() || "");
|
||||||
|
if (
|
||||||
|
html.includes(NOTE_TITLE_PREFIX) ||
|
||||||
|
html.includes(noteTitle) ||
|
||||||
|
html.includes("ChatPapers 伴读")
|
||||||
|
) {
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] findExistingLectureNote failed", e);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertLectureNote(options: {
|
||||||
|
parentItem: Zotero.Item;
|
||||||
|
data: LectureData;
|
||||||
|
paperTitle: string;
|
||||||
|
}): Promise<Zotero.Item> {
|
||||||
|
const noteTitle = `${NOTE_TITLE_PREFIX} ${options.paperTitle}`;
|
||||||
|
const bodyMarkdown = buildLectureNoteMarkdown(options.data, options.paperTitle);
|
||||||
|
const html = markdownToNoteHtml(noteTitle, bodyMarkdown);
|
||||||
|
|
||||||
|
const existing = findExistingLectureNote(options.parentItem, noteTitle);
|
||||||
|
if (existing) {
|
||||||
|
existing.setNote(html);
|
||||||
|
await existing.saveTx();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createChildNote({
|
||||||
|
parentItem: options.parentItem,
|
||||||
|
title: noteTitle,
|
||||||
|
bodyMarkdown,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type {
|
||||||
|
StoredParagraph,
|
||||||
|
StoredSentence,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
|
||||||
|
export function normalizeSelectionText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[\u0000-\u001f\f]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedSentenceMatch {
|
||||||
|
paragraph: StoredParagraph;
|
||||||
|
sentence: StoredSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapScore(a: string, b: string): number {
|
||||||
|
const na = normalizeSelectionText(a);
|
||||||
|
const nb = normalizeSelectionText(b);
|
||||||
|
if (!na || !nb) return 0;
|
||||||
|
if (na === nb) return 100;
|
||||||
|
if (na.includes(nb) || nb.includes(na)) {
|
||||||
|
const shorter = Math.min(na.length, nb.length);
|
||||||
|
const longer = Math.max(na.length, nb.length);
|
||||||
|
return Math.round((shorter / longer) * 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aWords = new Set(na.split(/\s+/).filter((w) => w.length > 2));
|
||||||
|
const bWords = nb.split(/\s+/).filter((w) => w.length > 2);
|
||||||
|
if (!aWords.size || !bWords.length) return 0;
|
||||||
|
let shared = 0;
|
||||||
|
for (const w of bWords) {
|
||||||
|
if (aWords.has(w)) shared += 1;
|
||||||
|
}
|
||||||
|
return Math.round((shared / bWords.length) * 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMatchingPreparedSentence(
|
||||||
|
paragraphs: StoredParagraph[] | undefined,
|
||||||
|
selectedText: string,
|
||||||
|
options?: { requireTts?: boolean },
|
||||||
|
): PreparedSentenceMatch | undefined {
|
||||||
|
if (!paragraphs?.length || !selectedText.trim()) return undefined;
|
||||||
|
|
||||||
|
const requireTts = options?.requireTts !== false;
|
||||||
|
let best: PreparedSentenceMatch | undefined;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
if (paragraph.genStatus !== "ready") continue;
|
||||||
|
|
||||||
|
for (const sentence of paragraph.sentences ?? []) {
|
||||||
|
if (requireTts && sentence.ttsZhStatus !== "ready") continue;
|
||||||
|
const score = overlapScore(selectedText, sentence.text);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = { paragraph, sentence };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paragraph.sentences?.length) {
|
||||||
|
const score = overlapScore(selectedText, paragraph.originalText);
|
||||||
|
if (score > bestScore && (!requireTts || paragraph.ttsZhStatus === "ready")) {
|
||||||
|
bestScore = score;
|
||||||
|
best = {
|
||||||
|
paragraph,
|
||||||
|
sentence: {
|
||||||
|
id: `${paragraph.id}-whole`,
|
||||||
|
orderIndex: 0,
|
||||||
|
text: paragraph.originalText,
|
||||||
|
translation: paragraph.translation,
|
||||||
|
page: paragraph.page,
|
||||||
|
searchText: paragraph.searchText,
|
||||||
|
audioPathEn: paragraph.audioPathEn,
|
||||||
|
audioPathZh: paragraph.audioPathZh,
|
||||||
|
ttsEnStatus: paragraph.ttsEnStatus,
|
||||||
|
ttsZhStatus: paragraph.ttsZhStatus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScore >= 55 ? best : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { StoredBeat } from "../infrastructure/storage/lectureStore";
|
||||||
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
|
|
||||||
|
export interface PlayLectureHandle {
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => void;
|
||||||
|
next: () => Promise<void>;
|
||||||
|
prev: () => Promise<void>;
|
||||||
|
jumpToBeatId: (beatId: string) => Promise<void>;
|
||||||
|
getCurrentBeatId: () => string | undefined;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
destroy: () => void;
|
||||||
|
setBeats: (beats: StoredBeat[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayLecture(options: {
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
beats: StoredBeat[];
|
||||||
|
onBeatChange?: (beatId: string | undefined) => void;
|
||||||
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}): PlayLectureHandle {
|
||||||
|
let beats = options.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
let index = 0;
|
||||||
|
let playing = false;
|
||||||
|
|
||||||
|
const notifyBeat = () => {
|
||||||
|
options.onBeatChange?.(beats[index]?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIndex = (i: number): boolean => {
|
||||||
|
if (i < 0 || i >= beats.length) return false;
|
||||||
|
index = i;
|
||||||
|
const beat = beats[i];
|
||||||
|
if (!beat.audioPath) return false;
|
||||||
|
options.audio.src = pathToFileUrl(beat.audioPath);
|
||||||
|
options.audio.load();
|
||||||
|
notifyBeat();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
options.audio.onended = () => {
|
||||||
|
void (async () => {
|
||||||
|
if (index + 1 < beats.length) {
|
||||||
|
index += 1;
|
||||||
|
if (loadIndex(index)) {
|
||||||
|
try {
|
||||||
|
await options.audio.play();
|
||||||
|
} catch {
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
options.onComplete?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setBeats(next) {
|
||||||
|
beats = next.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
if (index >= beats.length) index = Math.max(0, beats.length - 1);
|
||||||
|
notifyBeat();
|
||||||
|
},
|
||||||
|
|
||||||
|
async play() {
|
||||||
|
if (!beats.length) return;
|
||||||
|
if (!options.audio.src || options.audio.paused) {
|
||||||
|
loadIndex(index);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.audio.play();
|
||||||
|
playing = true;
|
||||||
|
options.onPlayingChange?.(true);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] audio play failed", e);
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async next() {
|
||||||
|
if (index + 1 >= beats.length) return;
|
||||||
|
index += 1;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
async prev() {
|
||||||
|
if (index <= 0) return;
|
||||||
|
index -= 1;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
async jumpToBeatId(beatId: string) {
|
||||||
|
const i = beats.findIndex((b) => b.id === beatId);
|
||||||
|
if (i < 0) return;
|
||||||
|
index = i;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentBeatId() {
|
||||||
|
return beats[index]?.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
isPlaying() {
|
||||||
|
return playing && !options.audio.paused;
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
options.audio.onended = null;
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import type {
|
||||||
|
StoredParagraph,
|
||||||
|
StoredSentence,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
|
|
||||||
|
export type ReadGranularity = "paragraph" | "sentence";
|
||||||
|
|
||||||
|
interface AudioSegment {
|
||||||
|
paragraphId: string;
|
||||||
|
sentenceId?: string;
|
||||||
|
kind: "en" | "zh";
|
||||||
|
audioPath: string;
|
||||||
|
highlightText: string;
|
||||||
|
page?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayParagraphHandle {
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => void;
|
||||||
|
next: () => Promise<void>;
|
||||||
|
prev: () => Promise<void>;
|
||||||
|
jumpToParagraphId: (paragraphId: string, kind?: "en" | "zh") => Promise<void>;
|
||||||
|
jumpToSentenceId: (sentenceId: string, kind?: "en" | "zh") => Promise<void>;
|
||||||
|
getCurrentParagraphId: () => string | undefined;
|
||||||
|
getCurrentSentenceId: () => string | undefined;
|
||||||
|
getCurrentKind: () => "en" | "zh" | undefined;
|
||||||
|
getCurrentHighlight: () => { text: string; page?: number } | undefined;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
destroy: () => void;
|
||||||
|
setParagraphs: (paragraphs: StoredParagraph[]) => void;
|
||||||
|
setGranularity: (granularity: ReadGranularity) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentenceReady(s: StoredSentence, kind: "en" | "zh"): boolean {
|
||||||
|
if (kind === "en") {
|
||||||
|
return s.ttsEnStatus === "ready" && !!s.audioPathEn;
|
||||||
|
}
|
||||||
|
return s.ttsZhStatus === "ready" && !!s.audioPathZh && !!s.translation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphReady(p: StoredParagraph, kind: "en" | "zh"): boolean {
|
||||||
|
if (kind === "en") {
|
||||||
|
return p.ttsEnStatus === "ready" && !!p.audioPathEn && p.genStatus === "ready";
|
||||||
|
}
|
||||||
|
return p.ttsZhStatus === "ready" && !!p.audioPathZh && p.genStatus === "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSentenceSegments(
|
||||||
|
segments: AudioSegment[],
|
||||||
|
paragraphId: string,
|
||||||
|
sentences: StoredSentence[],
|
||||||
|
): void {
|
||||||
|
for (const s of sentences) {
|
||||||
|
if (sentenceReady(s, "en")) {
|
||||||
|
segments.push({
|
||||||
|
paragraphId,
|
||||||
|
sentenceId: s.id,
|
||||||
|
kind: "en",
|
||||||
|
audioPath: s.audioPathEn!,
|
||||||
|
highlightText: s.text,
|
||||||
|
page: s.page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sentenceReady(s, "zh")) {
|
||||||
|
segments.push({
|
||||||
|
paragraphId,
|
||||||
|
sentenceId: s.id,
|
||||||
|
kind: "zh",
|
||||||
|
audioPath: s.audioPathZh!,
|
||||||
|
highlightText: s.text,
|
||||||
|
page: s.page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegments(
|
||||||
|
paragraphs: StoredParagraph[],
|
||||||
|
granularity: ReadGranularity,
|
||||||
|
): AudioSegment[] {
|
||||||
|
const segments: AudioSegment[] = [];
|
||||||
|
for (const p of paragraphs) {
|
||||||
|
if (p.genStatus !== "ready") continue;
|
||||||
|
|
||||||
|
const sentences = (p.sentences ?? []).filter(
|
||||||
|
(s) => sentenceReady(s, "en") || sentenceReady(s, "zh"),
|
||||||
|
);
|
||||||
|
const useSentences =
|
||||||
|
granularity === "sentence" &&
|
||||||
|
sentences.some((s) => sentenceReady(s, "en") || sentenceReady(s, "zh"));
|
||||||
|
|
||||||
|
if (useSentences) {
|
||||||
|
pushSentenceSegments(segments, p.id, sentences);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paragraphReady(p, "en")) {
|
||||||
|
segments.push({
|
||||||
|
paragraphId: p.id,
|
||||||
|
kind: "en",
|
||||||
|
audioPath: p.audioPathEn!,
|
||||||
|
highlightText: p.originalText,
|
||||||
|
page: p.page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (paragraphReady(p, "zh")) {
|
||||||
|
segments.push({
|
||||||
|
paragraphId: p.id,
|
||||||
|
kind: "zh",
|
||||||
|
audioPath: p.audioPathZh!,
|
||||||
|
highlightText: p.originalText,
|
||||||
|
page: p.page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createParagraphPlayer(options: {
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
paragraphs: StoredParagraph[];
|
||||||
|
granularity?: ReadGranularity;
|
||||||
|
onSegmentChange?: (info: {
|
||||||
|
paragraphId: string | undefined;
|
||||||
|
sentenceId: string | undefined;
|
||||||
|
kind: "en" | "zh" | undefined;
|
||||||
|
highlightText?: string;
|
||||||
|
page?: number;
|
||||||
|
}) => void;
|
||||||
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}): PlayParagraphHandle {
|
||||||
|
let granularity = options.granularity ?? "paragraph";
|
||||||
|
let segments = buildSegments(options.paragraphs, granularity);
|
||||||
|
let index = 0;
|
||||||
|
let playing = false;
|
||||||
|
|
||||||
|
const notify = () => {
|
||||||
|
const seg = segments[index];
|
||||||
|
options.onSegmentChange?.({
|
||||||
|
paragraphId: seg?.paragraphId,
|
||||||
|
sentenceId: seg?.sentenceId,
|
||||||
|
kind: seg?.kind,
|
||||||
|
highlightText: seg?.highlightText,
|
||||||
|
page: seg?.page,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIndex = (i: number): boolean => {
|
||||||
|
if (i < 0 || i >= segments.length) return false;
|
||||||
|
index = i;
|
||||||
|
const seg = segments[i];
|
||||||
|
options.audio.src = pathToFileUrl(seg.audioPath);
|
||||||
|
options.audio.load();
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
options.audio.onended = () => {
|
||||||
|
void (async () => {
|
||||||
|
if (index + 1 < segments.length) {
|
||||||
|
index += 1;
|
||||||
|
if (loadIndex(index)) {
|
||||||
|
try {
|
||||||
|
await options.audio.play();
|
||||||
|
} catch {
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
options.onComplete?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setGranularity(next) {
|
||||||
|
granularity = next;
|
||||||
|
const current = segments[index];
|
||||||
|
segments = buildSegments(options.paragraphs, granularity);
|
||||||
|
if (current) {
|
||||||
|
const i = segments.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.paragraphId === current.paragraphId &&
|
||||||
|
s.sentenceId === current.sentenceId &&
|
||||||
|
s.kind === current.kind,
|
||||||
|
);
|
||||||
|
index = i >= 0 ? i : 0;
|
||||||
|
} else {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
setParagraphs(next) {
|
||||||
|
options.paragraphs = next;
|
||||||
|
const current = segments[index];
|
||||||
|
segments = buildSegments(next, granularity);
|
||||||
|
if (current) {
|
||||||
|
const i = segments.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.paragraphId === current.paragraphId &&
|
||||||
|
s.sentenceId === current.sentenceId &&
|
||||||
|
s.kind === current.kind,
|
||||||
|
);
|
||||||
|
index = i >= 0 ? i : Math.min(index, Math.max(0, segments.length - 1));
|
||||||
|
} else if (index >= segments.length) {
|
||||||
|
index = Math.max(0, segments.length - 1);
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
async play() {
|
||||||
|
if (!segments.length) return;
|
||||||
|
if (!options.audio.src) {
|
||||||
|
loadIndex(index);
|
||||||
|
} else {
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.audio.play();
|
||||||
|
playing = true;
|
||||||
|
options.onPlayingChange?.(true);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] paragraph play failed", e);
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async next() {
|
||||||
|
if (index + 1 >= segments.length) return;
|
||||||
|
index += 1;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
async prev() {
|
||||||
|
if (index <= 0) return;
|
||||||
|
index -= 1;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
async jumpToParagraphId(paragraphId: string, kind: "en" | "zh" = "en") {
|
||||||
|
const i = segments.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.paragraphId === paragraphId &&
|
||||||
|
!s.sentenceId &&
|
||||||
|
(kind === "en" ? s.kind === "en" : s.kind === "zh"),
|
||||||
|
);
|
||||||
|
const fallback = segments.findIndex((s) => s.paragraphId === paragraphId);
|
||||||
|
const target = i >= 0 ? i : fallback;
|
||||||
|
if (target < 0) return;
|
||||||
|
index = target;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
async jumpToSentenceId(sentenceId: string, kind: "en" | "zh" = "en") {
|
||||||
|
const i = segments.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.sentenceId === sentenceId &&
|
||||||
|
(kind === "en" ? s.kind === "en" : s.kind === "zh"),
|
||||||
|
);
|
||||||
|
if (i < 0) return;
|
||||||
|
index = i;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentParagraphId() {
|
||||||
|
return segments[index]?.paragraphId;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentSentenceId() {
|
||||||
|
return segments[index]?.sentenceId;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentKind() {
|
||||||
|
return segments[index]?.kind;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentHighlight() {
|
||||||
|
const seg = segments[index];
|
||||||
|
if (!seg) return undefined;
|
||||||
|
return { text: seg.highlightText, page: seg.page };
|
||||||
|
},
|
||||||
|
|
||||||
|
isPlaying() {
|
||||||
|
return playing && !options.audio.paused;
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
options.audio.onended = null;
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
|
import type { SelectionExplainResult } from "./explainSelectionLecture";
|
||||||
|
|
||||||
|
export interface LecturePlaybackHandle {
|
||||||
|
pause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let playbackGeneration = 0;
|
||||||
|
let explainSessionId = 0;
|
||||||
|
const panePlaybackHandles = new Set<LecturePlaybackHandle>();
|
||||||
|
|
||||||
|
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
};
|
||||||
|
audio.onended = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Audio playback failed"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioPaths(result: SelectionExplainResult): string[] {
|
||||||
|
const paths: string[] = [];
|
||||||
|
if (result.audioPathEn) paths.push(result.audioPathEn);
|
||||||
|
if (result.audioPathZh) paths.push(result.audioPathZh);
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop standalone + all registered lecture pane players. */
|
||||||
|
export function stopAllLecturePlayback(): void {
|
||||||
|
playbackGeneration += 1;
|
||||||
|
standaloneAudio?.pause();
|
||||||
|
if (standaloneAudio) {
|
||||||
|
standaloneAudio.onended = null;
|
||||||
|
standaloneAudio.removeAttribute("src");
|
||||||
|
}
|
||||||
|
for (const handle of panePlaybackHandles) {
|
||||||
|
try {
|
||||||
|
handle.pause();
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] pause registered player failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLecturePlaybackHandle(
|
||||||
|
handle: LecturePlaybackHandle,
|
||||||
|
): () => void {
|
||||||
|
panePlaybackHandles.add(handle);
|
||||||
|
return () => {
|
||||||
|
panePlaybackHandles.delete(handle);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin a new explain flow; cancels any in-flight playback and explain session. */
|
||||||
|
export function beginSelectionExplainSession(): number {
|
||||||
|
explainSessionId += 1;
|
||||||
|
stopAllLecturePlayback();
|
||||||
|
return explainSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectionExplainSessionActive(sessionId: number): boolean {
|
||||||
|
return sessionId === explainSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelSelectionExplainSessions(): void {
|
||||||
|
explainSessionId += 1;
|
||||||
|
stopAllLecturePlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playSelectionExplainAudio(
|
||||||
|
audio: HTMLAudioElement,
|
||||||
|
result: SelectionExplainResult,
|
||||||
|
options?: { generation?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const paths = audioPaths(result);
|
||||||
|
if (!paths.length) return;
|
||||||
|
|
||||||
|
const generation = options?.generation ?? playbackGeneration;
|
||||||
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
if (generation !== playbackGeneration) return;
|
||||||
|
audio.src = pathToFileUrl(path);
|
||||||
|
audio.load();
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
} catch (e) {
|
||||||
|
if (generation !== playbackGeneration) return;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await waitAudioEnded(audio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let standaloneAudio: HTMLAudioElement | undefined;
|
||||||
|
|
||||||
|
function getStandaloneAudio(): HTMLAudioElement {
|
||||||
|
if (standaloneAudio) return standaloneAudio;
|
||||||
|
const win = Zotero.getMainWindow?.() as Window | undefined;
|
||||||
|
const doc = win?.document;
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error("Cannot resolve main window document for audio playback");
|
||||||
|
}
|
||||||
|
const audio = doc.createElement("audio");
|
||||||
|
audio.preload = "none";
|
||||||
|
audio.style.display = "none";
|
||||||
|
doc.body?.appendChild(audio);
|
||||||
|
standaloneAudio = audio;
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play selection explain on one audio element, stopping every other lecture audio first. */
|
||||||
|
export async function playSelectionExplainExclusive(
|
||||||
|
result: SelectionExplainResult,
|
||||||
|
audio?: HTMLAudioElement,
|
||||||
|
): Promise<void> {
|
||||||
|
stopAllLecturePlayback();
|
||||||
|
const generation = playbackGeneration;
|
||||||
|
await playSelectionExplainAudio(audio ?? getStandaloneAudio(), result, {
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playSelectionExplainStandalone(
|
||||||
|
result: SelectionExplainResult,
|
||||||
|
): Promise<void> {
|
||||||
|
await playSelectionExplainExclusive(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseStandalonePlayback(): void {
|
||||||
|
standaloneAudio?.pause();
|
||||||
|
if (standaloneAudio) {
|
||||||
|
standaloneAudio.onended = null;
|
||||||
|
standaloneAudio.removeAttribute("src");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSelectionExplainStandalone(): void {
|
||||||
|
stopAllLecturePlayback();
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import type { LectureStatus, PaperCache } from "../domain/types";
|
||||||
|
import { upsertLectureNote } from "./generateLectureNote";
|
||||||
|
import { generateBeats } from "../infrastructure/llm/beatLlm";
|
||||||
|
import {
|
||||||
|
generatePaperSummary,
|
||||||
|
} from "../infrastructure/llm/lectureLlm";
|
||||||
|
import {
|
||||||
|
buildRollingContext,
|
||||||
|
generateParagraphExplanation,
|
||||||
|
isPrimarilyEnglish,
|
||||||
|
toStoredParagraph,
|
||||||
|
} from "../infrastructure/llm/paragraphLlm";
|
||||||
|
import {
|
||||||
|
chunkPaperText,
|
||||||
|
formatChunkOutline,
|
||||||
|
} from "../infrastructure/pdf/chunker";
|
||||||
|
import { splitSentences } from "../infrastructure/pdf/sentenceSplit";
|
||||||
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import {
|
||||||
|
ensureAudioDir,
|
||||||
|
} from "../infrastructure/storage/audioStorage";
|
||||||
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
|
import {
|
||||||
|
isLectureComplete,
|
||||||
|
loadLectureData,
|
||||||
|
mergeLectureData,
|
||||||
|
type LectureData,
|
||||||
|
type StoredParagraph,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
import { audioFilePath } from "../infrastructure/storage/paths";
|
||||||
|
import { paperRepository } from "../infrastructure/storage/repository";
|
||||||
|
import { synthesizeSpeech } from "../infrastructure/tts/client";
|
||||||
|
import {
|
||||||
|
getRegularItem,
|
||||||
|
resolvePaperAttachment,
|
||||||
|
} from "../infrastructure/zotero/adapter";
|
||||||
|
import {
|
||||||
|
buildMetadataBlock,
|
||||||
|
extractPdfContext,
|
||||||
|
} from "../../pdf/extractor";
|
||||||
|
import { LlmError } from "../../llm/types";
|
||||||
|
|
||||||
|
/** Max paragraphs for Step 2 (long papers: lazy gen later). */
|
||||||
|
const MAX_PARAGRAPH_UNITS = 25;
|
||||||
|
const TTS_EN_MAX_CHARS = 600;
|
||||||
|
const TTS_SENTENCE_ZH_MAX_CHARS = 280;
|
||||||
|
|
||||||
|
export class PrepareLectureUseCase {
|
||||||
|
private inflight = new Map<string, Promise<PaperCache>>();
|
||||||
|
|
||||||
|
async start(item: Zotero.Item): Promise<PaperCache> {
|
||||||
|
const ctx = await resolvePaperAttachment(item);
|
||||||
|
const pending = this.inflight.get(ctx.cacheKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const task = this.runStart(item, ctx);
|
||||||
|
this.inflight.set(ctx.cacheKey, task);
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
this.inflight.delete(ctx.cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runStart(
|
||||||
|
item: Zotero.Item,
|
||||||
|
ctx: Awaited<ReturnType<typeof resolvePaperAttachment>>,
|
||||||
|
): Promise<PaperCache> {
|
||||||
|
let paper = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const parent = getRegularItem(item);
|
||||||
|
const paperId = paper?.id ?? paperIdFromCacheKey(ctx.cacheKey);
|
||||||
|
|
||||||
|
if (!paper) {
|
||||||
|
paper = {
|
||||||
|
id: paperId,
|
||||||
|
attachmentId: String(ctx.attachment.id),
|
||||||
|
itemId: String(parent.id),
|
||||||
|
fileHash: ctx.fileHash,
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
title: String(parent.getField("title") || ""),
|
||||||
|
parseStatus: "pending",
|
||||||
|
lectureStatus: "idle",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await loadLectureData(ctx.cacheKey);
|
||||||
|
if (paper.lectureStatus === "ready" && isLectureComplete(data)) {
|
||||||
|
return paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runPipeline(paper, ctx.cacheKey, item);
|
||||||
|
const updated = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
return updated ?? paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPipeline(
|
||||||
|
paper: PaperCache,
|
||||||
|
cacheKey: string,
|
||||||
|
item: Zotero.Item,
|
||||||
|
): Promise<void> {
|
||||||
|
const emitStatus = async (
|
||||||
|
lectureStatus: LectureStatus,
|
||||||
|
message: string,
|
||||||
|
percent?: number,
|
||||||
|
) => {
|
||||||
|
paper.lectureStatus = lectureStatus;
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
lectureEvents.emit("lecture:status", {
|
||||||
|
paperId: paper.id,
|
||||||
|
lectureStatus,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
if (percent != null) {
|
||||||
|
lectureEvents.emit("parse:progress", {
|
||||||
|
paperId: paper.id,
|
||||||
|
percent,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data: LectureData =
|
||||||
|
(await loadLectureData(cacheKey)) ?? {
|
||||||
|
paperId: paper.id,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = getRegularItem(item);
|
||||||
|
const title = String(parent.getField("title") || paper.title || "");
|
||||||
|
|
||||||
|
// --- PDF extract ---
|
||||||
|
let extractText = "";
|
||||||
|
if (!data.summary || !data.chunks?.length) {
|
||||||
|
paper.parseStatus = "running";
|
||||||
|
await emitStatus("parsing", "正在读取论文…", 10);
|
||||||
|
|
||||||
|
const extract = await extractPdfContext(item);
|
||||||
|
if (!extract.text) {
|
||||||
|
throw new Error(
|
||||||
|
extract.error ||
|
||||||
|
"未能提取 PDF 文本(可能是扫描版)。语音伴读需要可复制的文本层。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
extractText = extract.text;
|
||||||
|
data.chunks = chunkPaperText(extract.text);
|
||||||
|
data.extractChars = extract.text.length;
|
||||||
|
data.extractSource = extract.source;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"parsing",
|
||||||
|
`已提取 ${extract.text.length} 字符,${data.chunks.length} 个段落块`,
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
paper.parseStatus = "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
if (!data.summary) {
|
||||||
|
await emitStatus("summarizing", "正在生成全文摘要…", 35);
|
||||||
|
|
||||||
|
const extract = await extractPdfContext(item);
|
||||||
|
const meta = buildMetadataBlock(parent);
|
||||||
|
const paperText =
|
||||||
|
extract.text || extractText || data.chunks?.map((c) => c.text).join("\n\n") || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.summary = await generatePaperSummary({
|
||||||
|
title,
|
||||||
|
paperText: meta ? `${meta}\n\n${paperText}` : paperText,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
throw new Error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
data.phase = 1;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Beats ---
|
||||||
|
if (!data.beats?.length) {
|
||||||
|
await emitStatus("beats_generating", "正在生成整体讲解 beats…", 50);
|
||||||
|
|
||||||
|
const chunks = data.chunks ?? [];
|
||||||
|
try {
|
||||||
|
data.beats = await generateBeats({
|
||||||
|
title,
|
||||||
|
summary: data.summary!,
|
||||||
|
chunkOutline: formatChunkOutline(chunks),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
throw new Error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
lectureEvents.emit("lecture:beats_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
beatCount: data.beats.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TTS for beats ---
|
||||||
|
const beats = data.beats ?? [];
|
||||||
|
const pendingTts = beats.filter((b) => b.ttsStatus !== "ready");
|
||||||
|
if (pendingTts.length) {
|
||||||
|
await emitStatus(
|
||||||
|
"tts_generating",
|
||||||
|
`正在合成语音(0/${pendingTts.length})…`,
|
||||||
|
65,
|
||||||
|
);
|
||||||
|
await ensureAudioDir(cacheKey);
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
for (const beat of beats) {
|
||||||
|
if (beat.ttsStatus === "ready" && beat.audioPath) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outPath = audioFilePath(cacheKey, beat.id, "wav");
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: beat.script,
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
beat.audioPath = result.audioPath;
|
||||||
|
beat.ttsStatus = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] TTS failed for", beat.id, e);
|
||||||
|
beat.ttsStatus = "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
done += 1;
|
||||||
|
data.beats = beats;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
if (beat.ttsStatus === "ready" && beat.audioPath) {
|
||||||
|
lectureEvents.emit("tts:unit_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
unitId: beat.id,
|
||||||
|
audioPath: beat.audioPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"tts_generating",
|
||||||
|
`正在合成语音(${done}/${pendingTts.length})…`,
|
||||||
|
65 + Math.floor((done / pendingTts.length) * 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beats.some((b) => b.ttsStatus === "ready")) {
|
||||||
|
throw new Error("所有 beat 语音合成均失败,请检查 TTS 设置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
data.phase = 2;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
// --- Paragraph explanations (Step 2) ---
|
||||||
|
const targetChunks = (data.chunks ?? []).slice(0, MAX_PARAGRAPH_UNITS);
|
||||||
|
let paragraphs: StoredParagraph[] = data.paragraphs ?? [];
|
||||||
|
|
||||||
|
const needsParaGen = targetChunks.some(
|
||||||
|
(c) =>
|
||||||
|
!paragraphs.find((p) => p.id === c.id && p.genStatus === "ready"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needsParaGen && targetChunks.length) {
|
||||||
|
await emitStatus(
|
||||||
|
"paragraphs_generating",
|
||||||
|
`正在生成逐段译文(0/${targetChunks.length})…`,
|
||||||
|
70,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolling: Array<{ translation: string; contextLink: string }> = [];
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
for (const chunk of targetChunks) {
|
||||||
|
const existing = paragraphs.find((p) => p.id === chunk.id);
|
||||||
|
if (existing?.genStatus === "ready") {
|
||||||
|
rolling.push({
|
||||||
|
translation: existing.translation,
|
||||||
|
contextLink: existing.contextLink,
|
||||||
|
});
|
||||||
|
if (rolling.length > 3) rolling.shift();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const priorContext = buildRollingContext(rolling);
|
||||||
|
const llm = await generateParagraphExplanation({
|
||||||
|
paperTitle: title,
|
||||||
|
summary: data.summary!,
|
||||||
|
chunk,
|
||||||
|
priorContext,
|
||||||
|
});
|
||||||
|
const para = toStoredParagraph(chunk, llm);
|
||||||
|
paragraphs = paragraphs.filter((p) => p.id !== chunk.id);
|
||||||
|
paragraphs.push(para);
|
||||||
|
rolling.push({
|
||||||
|
translation: para.translation,
|
||||||
|
contextLink: para.contextLink,
|
||||||
|
});
|
||||||
|
if (rolling.length > 3) rolling.shift();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] paragraph gen failed", chunk.id, e);
|
||||||
|
paragraphs = paragraphs.filter((p) => p.id !== chunk.id);
|
||||||
|
paragraphs.push({
|
||||||
|
id: chunk.id,
|
||||||
|
orderIndex: chunk.orderIndex,
|
||||||
|
originalText: chunk.text,
|
||||||
|
translation: chunk.text.slice(0, 300),
|
||||||
|
contextLink: "",
|
||||||
|
roleInPaper: "",
|
||||||
|
ttsEnStatus: isPrimarilyEnglish(chunk.text) ? "pending" : "skipped",
|
||||||
|
ttsZhStatus: "pending",
|
||||||
|
genStatus: "failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done += 1;
|
||||||
|
data.paragraphs = paragraphs.sort(
|
||||||
|
(a, b) => a.orderIndex - b.orderIndex,
|
||||||
|
);
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
lectureEvents.emit("lecture:paragraph_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
paragraphId: chunk.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"paragraphs_generating",
|
||||||
|
`正在生成逐段译文(${done}/${targetChunks.length})…`,
|
||||||
|
70 + Math.floor((done / targetChunks.length) * 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Backfill sentences for cached paragraphs (re-prepare upgrade) ---
|
||||||
|
paragraphs = (data.paragraphs ?? []).sort(
|
||||||
|
(a, b) => a.orderIndex - b.orderIndex,
|
||||||
|
);
|
||||||
|
let backfilled = false;
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
if (para.genStatus === "ready" && !(para.sentences?.length ?? 0)) {
|
||||||
|
const chunk = targetChunks.find((c) => c.id === para.id);
|
||||||
|
para.sentences = splitSentences(para.originalText).map(
|
||||||
|
(text, orderIndex) => ({
|
||||||
|
id: `${para.id}-s${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
text,
|
||||||
|
translation:
|
||||||
|
orderIndex === 0 ? para.translation.slice(0, 120) : "",
|
||||||
|
page: chunk?.page ?? para.page,
|
||||||
|
searchText: text.slice(0, 80),
|
||||||
|
ttsEnStatus: isPrimarilyEnglish(text)
|
||||||
|
? ("pending" as const)
|
||||||
|
: ("skipped" as const),
|
||||||
|
ttsZhStatus: "pending" as const,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
backfilled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backfilled) {
|
||||||
|
data.paragraphs = paragraphs;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TTS for paragraphs (EN then ZH per unit; then per-sentence) ---
|
||||||
|
const paraTtsPending = paragraphs.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.ttsEnStatus === "pending" && isPrimarilyEnglish(p.originalText)) ||
|
||||||
|
p.ttsZhStatus !== "ready" ||
|
||||||
|
(p.sentences ?? []).some(
|
||||||
|
(s) =>
|
||||||
|
(s.ttsEnStatus === "pending" &&
|
||||||
|
isPrimarilyEnglish(s.text)) ||
|
||||||
|
s.ttsZhStatus === "pending",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paraTtsPending.length) {
|
||||||
|
await ensureAudioDir(cacheKey);
|
||||||
|
let ttsDone = 0;
|
||||||
|
const ttsTotal = paraTtsPending.length;
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
para.ttsEnStatus === "pending" &&
|
||||||
|
isPrimarilyEnglish(para.originalText)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const enText = para.originalText.slice(0, TTS_EN_MAX_CHARS);
|
||||||
|
const outPath = audioFilePath(cacheKey, `${para.id}-en`, "wav");
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: enText,
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "en",
|
||||||
|
});
|
||||||
|
para.audioPathEn = result.audioPath;
|
||||||
|
para.ttsEnStatus = "ready";
|
||||||
|
updated = true;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] EN TTS failed", para.id, e);
|
||||||
|
para.ttsEnStatus = "failed";
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (para.ttsZhStatus !== "ready" && para.translation) {
|
||||||
|
try {
|
||||||
|
const outPath = audioFilePath(cacheKey, `${para.id}-zh`, "wav");
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: para.translation.slice(0, 1200),
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
para.audioPathZh = result.audioPath;
|
||||||
|
para.ttsZhStatus = "ready";
|
||||||
|
updated = true;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] ZH TTS failed", para.id, e);
|
||||||
|
para.ttsZhStatus = "failed";
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sentence of para.sentences ?? []) {
|
||||||
|
if (
|
||||||
|
sentence.ttsEnStatus === "pending" &&
|
||||||
|
isPrimarilyEnglish(sentence.text)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const outPath = audioFilePath(
|
||||||
|
cacheKey,
|
||||||
|
`${sentence.id}-en`,
|
||||||
|
"wav",
|
||||||
|
);
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: sentence.text.slice(0, TTS_EN_MAX_CHARS),
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "en",
|
||||||
|
});
|
||||||
|
sentence.audioPathEn = result.audioPath;
|
||||||
|
sentence.ttsEnStatus = "ready";
|
||||||
|
updated = true;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log(
|
||||||
|
"[ChatPapers:Lecture] sentence EN TTS failed",
|
||||||
|
sentence.id,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
sentence.ttsEnStatus = "failed";
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentence.ttsZhStatus === "pending" && sentence.translation) {
|
||||||
|
try {
|
||||||
|
const outPath = audioFilePath(
|
||||||
|
cacheKey,
|
||||||
|
`${sentence.id}-zh`,
|
||||||
|
"wav",
|
||||||
|
);
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: sentence.translation.slice(0, TTS_SENTENCE_ZH_MAX_CHARS),
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
sentence.audioPathZh = result.audioPath;
|
||||||
|
sentence.ttsZhStatus = "ready";
|
||||||
|
updated = true;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log(
|
||||||
|
"[ChatPapers:Lecture] sentence ZH TTS failed",
|
||||||
|
sentence.id,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
sentence.ttsZhStatus = "failed";
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
ttsDone += 1;
|
||||||
|
data.paragraphs = paragraphs;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
if (para.audioPathZh) {
|
||||||
|
lectureEvents.emit("tts:unit_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
unitId: para.id,
|
||||||
|
audioPath: para.audioPathZh,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"tts_generating",
|
||||||
|
`正在合成逐段/逐句语音(${ttsDone}/${ttsTotal})…`,
|
||||||
|
82 + Math.floor((ttsDone / ttsTotal) * 15),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paragraphs.some((p) => p.ttsZhStatus === "ready")) {
|
||||||
|
throw new Error("逐段中文语音均合成失败,请检查 TTS 设置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zotero note ---
|
||||||
|
try {
|
||||||
|
const note = await upsertLectureNote({
|
||||||
|
parentItem: parent,
|
||||||
|
data: { ...data, paragraphs, summary: data.summary! },
|
||||||
|
paperTitle: title,
|
||||||
|
});
|
||||||
|
data.noteItemId = String(note.id);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] upsert note failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.phase = 3;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
paper.lectureStatus = "ready";
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
|
||||||
|
const readyBeats = beats.filter((b) => b.ttsStatus === "ready").length;
|
||||||
|
const readyParas = paragraphs.filter((p) => p.ttsZhStatus === "ready").length;
|
||||||
|
lectureEvents.emit("lecture:status", {
|
||||||
|
paperId: paper.id,
|
||||||
|
lectureStatus: "ready",
|
||||||
|
message: `备课完成:${readyParas} 段逐段精读 + ${readyBeats} 段整体讲解\n笔记已写入 Zotero 子笔记`,
|
||||||
|
});
|
||||||
|
lectureEvents.emit("parse:progress", {
|
||||||
|
paperId: paper.id,
|
||||||
|
percent: 100,
|
||||||
|
message: "逐段精读与笔记已就绪",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
paper.parseStatus = "failed";
|
||||||
|
paper.lectureStatus = "failed";
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
lectureEvents.emit("lecture:failed", {
|
||||||
|
paperId: paper.id,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prepareLecture = new PrepareLectureUseCase();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/** 备课 / 解析流水线状态 */
|
||||||
|
export type LectureStatus =
|
||||||
|
| "idle"
|
||||||
|
| "parsing"
|
||||||
|
| "parsed"
|
||||||
|
| "summarizing"
|
||||||
|
| "beats_generating"
|
||||||
|
| "paragraphs_generating"
|
||||||
|
| "tts_generating"
|
||||||
|
| "ready"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type ParseStatus = "pending" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export type BeatType =
|
||||||
|
| "intro"
|
||||||
|
| "method"
|
||||||
|
| "experiment"
|
||||||
|
| "conclusion"
|
||||||
|
| "extension";
|
||||||
|
|
||||||
|
export type PlayerMode = "overview" | "paragraph";
|
||||||
|
|
||||||
|
export type AnchorType = "beat" | "paragraph" | "sentence" | "global";
|
||||||
|
|
||||||
|
export interface SummaryJson {
|
||||||
|
problem: string;
|
||||||
|
method: string;
|
||||||
|
result: string;
|
||||||
|
limitation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
title: string;
|
||||||
|
orderIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paragraph {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
bulletPoints?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Beat {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
title?: string;
|
||||||
|
type: BeatType;
|
||||||
|
script: string;
|
||||||
|
refs: string[];
|
||||||
|
degraded?: boolean;
|
||||||
|
audioPath?: string;
|
||||||
|
timestampsJson?: string;
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParagraphExplanation {
|
||||||
|
paragraphId: string;
|
||||||
|
paperId: string;
|
||||||
|
layerWhat: string;
|
||||||
|
layerHow: string;
|
||||||
|
layerWhere: string;
|
||||||
|
audioPath?: string;
|
||||||
|
timestampsJson?: string;
|
||||||
|
genStatus: "pending" | "ready" | "failed";
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperCache {
|
||||||
|
id: string;
|
||||||
|
attachmentId: string;
|
||||||
|
itemId: string;
|
||||||
|
fileHash: string;
|
||||||
|
filePath: string;
|
||||||
|
title?: string;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
parseStatus: ParseStatus;
|
||||||
|
lectureStatus: LectureStatus;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperAttachmentContext {
|
||||||
|
item: Zotero.Item;
|
||||||
|
attachment: Zotero.Item;
|
||||||
|
filePath: string;
|
||||||
|
fileHash: string;
|
||||||
|
cacheKey: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type LectureEventMap = {
|
||||||
|
"parse:progress": { paperId: string; percent: number; message: string };
|
||||||
|
"lecture:beats_ready": { paperId: string; beatCount: number };
|
||||||
|
"lecture:paragraph_ready": { paperId: string; paragraphId: string };
|
||||||
|
"tts:unit_ready": { paperId: string; unitId: string; audioPath: string };
|
||||||
|
"lecture:failed": { paperId: string; error: string };
|
||||||
|
"lecture:status": {
|
||||||
|
paperId: string;
|
||||||
|
lectureStatus: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
"lecture:selection_explain": {
|
||||||
|
paperId: string;
|
||||||
|
cacheKey: string;
|
||||||
|
itemId: string;
|
||||||
|
result: import("../application/explainSelectionLecture").SelectionExplainResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler<K extends keyof LectureEventMap> = (
|
||||||
|
detail: LectureEventMap[K],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process event bus for lecture pipeline.
|
||||||
|
* Avoids EventTarget / CustomEvent — not available in Zotero chrome scopes.
|
||||||
|
*/
|
||||||
|
class LectureEventBus {
|
||||||
|
private listeners = new Map<
|
||||||
|
keyof LectureEventMap,
|
||||||
|
Set<Handler<keyof LectureEventMap>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
on<K extends keyof LectureEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: Handler<K>,
|
||||||
|
): () => void {
|
||||||
|
let set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
const fn = handler as Handler<keyof LectureEventMap>;
|
||||||
|
set.add(fn);
|
||||||
|
return () => set.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof LectureEventMap>(
|
||||||
|
type: K,
|
||||||
|
detail: LectureEventMap[K],
|
||||||
|
): void {
|
||||||
|
const set = this.listeners.get(type);
|
||||||
|
if (!set) return;
|
||||||
|
for (const handler of set) {
|
||||||
|
try {
|
||||||
|
handler(detail);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] event handler error", type, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lectureEvents = new LectureEventBus();
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { BeatType, SummaryJson } from "../../domain/types";
|
||||||
|
import type { StoredBeat } from "../storage/lectureStore";
|
||||||
|
import { FORCE_ZH_EXPLANATION } from "./promptRules";
|
||||||
|
import { parseJsonLoose, requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set<BeatType>([
|
||||||
|
"intro",
|
||||||
|
"method",
|
||||||
|
"experiment",
|
||||||
|
"conclusion",
|
||||||
|
"extension",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BEATS_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文摘要与段落索引,生成「整体讲解」beats。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
|
||||||
|
格式:{"beats":[{"title":"小节标题","type":"intro","script":"口语讲解文本"}]}
|
||||||
|
要求:
|
||||||
|
- beats 数组含 6–10 项,按教学逻辑:问题引入 → 核心方法 → 关键实验/证据 → 结论与局限
|
||||||
|
- script 为中文口语,每段 80–180 字,适合朗读;不要念公式符号,用白话解释
|
||||||
|
- type 只能是 intro / method / experiment / conclusion / extension
|
||||||
|
|
||||||
|
${FORCE_ZH_EXPLANATION}`;
|
||||||
|
|
||||||
|
interface BeatJson {
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
script?: string;
|
||||||
|
content?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beatScript(b: BeatJson): string {
|
||||||
|
return String(b.script || b.content || b.text || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBeatType(raw?: string): BeatType {
|
||||||
|
const t = String(raw || "intro").toLowerCase() as BeatType;
|
||||||
|
return VALID_TYPES.has(t) ? t : "intro";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStoredBeats(items: BeatJson[]): StoredBeat[] {
|
||||||
|
return items
|
||||||
|
.map((b) => ({ ...b, script: beatScript(b) }))
|
||||||
|
.filter((b) => b.script)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((b, orderIndex) => ({
|
||||||
|
id: `beat-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
title: String(b.title || `第 ${orderIndex + 1} 段`).trim(),
|
||||||
|
type: normalizeBeatType(b.type),
|
||||||
|
script: b.script,
|
||||||
|
refs: [],
|
||||||
|
ttsStatus: "pending" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBeatsJson(raw: string): StoredBeat[] {
|
||||||
|
const parsed = parseJsonLoose<BeatJson[] | { beats?: BeatJson[] }>(raw);
|
||||||
|
const items = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: Array.isArray(parsed?.beats)
|
||||||
|
? parsed.beats!
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const beats = toStoredBeats(items);
|
||||||
|
if (beats.length) return beats;
|
||||||
|
|
||||||
|
throw new LlmError("provider", "beats 数组为空或格式无效,请重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic fallback when JSON parsing fails — still usable for TTS. */
|
||||||
|
export function beatsFromSummaryFallback(summary: SummaryJson): StoredBeat[] {
|
||||||
|
const templates: Array<{ title: string; type: BeatType; script: string }> = [
|
||||||
|
{ title: "研究问题", type: "intro", script: summary.problem },
|
||||||
|
{ title: "核心方法", type: "method", script: summary.method },
|
||||||
|
{
|
||||||
|
title: "方法细节",
|
||||||
|
type: "method",
|
||||||
|
script: `${summary.method} 这部分是理解全文的关键,建议结合原文图表再听一遍。`,
|
||||||
|
},
|
||||||
|
{ title: "主要结果", type: "experiment", script: summary.result },
|
||||||
|
{ title: "结论与局限", type: "conclusion", script: summary.limitation },
|
||||||
|
];
|
||||||
|
|
||||||
|
return templates
|
||||||
|
.filter((t) => t.script.trim())
|
||||||
|
.map((t, orderIndex) => ({
|
||||||
|
id: `beat-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
title: t.title,
|
||||||
|
type: t.type,
|
||||||
|
script: t.script.trim(),
|
||||||
|
refs: [],
|
||||||
|
ttsStatus: "pending" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateBeats(options: {
|
||||||
|
title: string;
|
||||||
|
summary: SummaryJson;
|
||||||
|
chunkOutline: string;
|
||||||
|
}): Promise<StoredBeat[]> {
|
||||||
|
const summaryBlock = [
|
||||||
|
`问题:${options.summary.problem}`,
|
||||||
|
`方法:${options.summary.method}`,
|
||||||
|
`结果:${options.summary.result}`,
|
||||||
|
`局限:${options.summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: BEATS_SYSTEM,
|
||||||
|
user: `【论文标题】${options.title}
|
||||||
|
|
||||||
|
【全文摘要】
|
||||||
|
${summaryBlock}
|
||||||
|
|
||||||
|
【段落索引(供参考)】
|
||||||
|
${options.chunkOutline.slice(0, 8000)}
|
||||||
|
|
||||||
|
请输出 {"beats":[...]} JSON。`,
|
||||||
|
parse: parseBeatsJson,
|
||||||
|
jsonMode: true,
|
||||||
|
fallback: () => beatsFromSummaryFallback(options.summary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BEAT_TYPE_LABELS: Record<BeatType, string> = {
|
||||||
|
intro: "引入",
|
||||||
|
method: "方法",
|
||||||
|
experiment: "实验",
|
||||||
|
conclusion: "结论",
|
||||||
|
extension: "延伸",
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { getRuntimeConfig } from "../../../llm/client";
|
||||||
|
import { parseJsonLoose, parseLabeledFields } from "../../../llm/jsonParse";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { SummaryJson } from "../../domain/types";
|
||||||
|
import { FORCE_ZH_EXPLANATION } from "./promptRules";
|
||||||
|
import { requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const SUMMARY_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文正文生成结构化摘要。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
|
||||||
|
格式:{"problem":"...","method":"...","result":"...","limitation":"..."}
|
||||||
|
每项 1-3 句中文,准确、简洁,基于正文,不要编造。
|
||||||
|
|
||||||
|
${FORCE_ZH_EXPLANATION}`;
|
||||||
|
|
||||||
|
const SUMMARY_LABELS = [
|
||||||
|
{ field: "problem", labels: ["问题", "研究问题", "problem"] },
|
||||||
|
{ field: "method", labels: ["方法", "核心方法", "method"] },
|
||||||
|
{ field: "result", labels: ["结果", "主要结果", "result"] },
|
||||||
|
{ field: "limitation", labels: ["局限", "局限性", "limitation"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeSummary(obj: Record<string, unknown>): SummaryJson | undefined {
|
||||||
|
const problem = String(obj.problem || "").trim();
|
||||||
|
const method = String(obj.method || "").trim();
|
||||||
|
const result = String(obj.result || obj.results || "").trim();
|
||||||
|
const limitation = String(obj.limitation || obj.limitations || "").trim();
|
||||||
|
if (!problem || !method) return undefined;
|
||||||
|
return {
|
||||||
|
problem,
|
||||||
|
method,
|
||||||
|
result: result || "(模型未单独给出结果,请重试或换模型)",
|
||||||
|
limitation: limitation || "(模型未单独给出局限,请重试或换模型)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummaryJson(raw: string): SummaryJson {
|
||||||
|
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
|
||||||
|
const summary = normalizeSummary(parsed);
|
||||||
|
if (summary) return summary;
|
||||||
|
|
||||||
|
const labeled = parseLabeledFields(raw, SUMMARY_LABELS);
|
||||||
|
const fromLabels = normalizeSummary(labeled);
|
||||||
|
if (fromLabels) return fromLabels;
|
||||||
|
|
||||||
|
throw new LlmError("provider", "模型未返回有效 JSON 摘要,请重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePaperSummary(options: {
|
||||||
|
title: string;
|
||||||
|
paperText: string;
|
||||||
|
}): Promise<SummaryJson> {
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
const text = options.paperText.slice(0, Number(cfg.maxTokens) * 3 || 120000);
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: SUMMARY_SYSTEM,
|
||||||
|
user: `【论文标题】${options.title}\n\n【论文正文】\n${text}\n\n请输出 JSON 摘要。`,
|
||||||
|
parse: parseSummaryJson,
|
||||||
|
jsonMode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSummaryForDisplay(summary: SummaryJson): string {
|
||||||
|
return [
|
||||||
|
`问题:${summary.problem}`,
|
||||||
|
`方法:${summary.method}`,
|
||||||
|
`结果:${summary.result}`,
|
||||||
|
`局限:${summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { parseJsonLoose } from "../../../llm/jsonParse";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { SummaryJson } from "../../domain/types";
|
||||||
|
import type { TextChunk } from "../pdf/chunker";
|
||||||
|
import { splitSentences } from "../pdf/sentenceSplit";
|
||||||
|
import type { StoredParagraph, StoredSentence } from "../storage/lectureStore";
|
||||||
|
import { FORCE_ZH_EXPLANATION, FORCE_ZH_USER_REMINDER } from "./promptRules";
|
||||||
|
import { requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const PARA_SYSTEM = `你是 ChatPapers 论文伴读助手。用户会按论文物理顺序逐段听课。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块。
|
||||||
|
格式:{"translation":"...","contextLink":"...","roleInPaper":"...","sentences":[{"original":"...","translation":"..."}]}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- translation:连贯的中文。英文段落需先准确翻译,再用简短口语解释难词/公式(不要念 LaTeX);中文段落则做白话梳理。
|
||||||
|
- contextLink:1–2 句,说明本段与上一段及全文摘要的衔接(代词指代、逻辑转折等)。
|
||||||
|
- roleInPaper:本段在论文结构中的作用(如「方法开篇」「实验设置」)。
|
||||||
|
- sentences:将本段拆成 2–8 个朗读单元(按句号/问号/叹号分句),每项含 original(原文句)与 translation(该句的中文讲解,30–80 字)。
|
||||||
|
- 术语与专有名词翻译前后一致;指代前文概念时用相同译法。
|
||||||
|
|
||||||
|
${FORCE_ZH_EXPLANATION}`;
|
||||||
|
|
||||||
|
export interface ParagraphLlmResult {
|
||||||
|
translation: string;
|
||||||
|
contextLink: string;
|
||||||
|
roleInPaper: string;
|
||||||
|
sentences: Array<{ original: string; translation: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSentences(
|
||||||
|
raw: unknown,
|
||||||
|
chunkText: string,
|
||||||
|
): Array<{ original: string; translation: string }> {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const parsed = raw
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const o = item as Record<string, unknown>;
|
||||||
|
const original = String(o.original || o.text || o.en || "").trim();
|
||||||
|
const translation = String(o.translation || o.zh || "").trim();
|
||||||
|
if (!original) return null;
|
||||||
|
return { original, translation: translation || original };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ original: string; translation: string }>;
|
||||||
|
|
||||||
|
if (parsed.length) return parsed.slice(0, 12);
|
||||||
|
|
||||||
|
return splitSentences(chunkText).map((s) => ({
|
||||||
|
original: s,
|
||||||
|
translation: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParagraphJson(raw: string, chunkText: string): ParagraphLlmResult {
|
||||||
|
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
|
||||||
|
const translation = String(parsed.translation || parsed.zh || "").trim();
|
||||||
|
if (!translation) {
|
||||||
|
throw new LlmError("provider", "段落讲解 JSON 缺少 translation 字段。");
|
||||||
|
}
|
||||||
|
const sentences = parseSentences(parsed.sentences, chunkText);
|
||||||
|
return {
|
||||||
|
translation,
|
||||||
|
contextLink: String(parsed.contextLink || parsed.context || "").trim(),
|
||||||
|
roleInPaper: String(parsed.roleInPaper || parsed.role || "").trim(),
|
||||||
|
sentences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackParagraph(chunk: TextChunk): ParagraphLlmResult {
|
||||||
|
const sentences = splitSentences(chunk.text).map((s) => ({
|
||||||
|
original: s,
|
||||||
|
translation: s.slice(0, 120),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
translation: chunk.text.slice(0, 500),
|
||||||
|
contextLink: "(模型未能生成衔接说明,请重试备课或更换模型。)",
|
||||||
|
roleInPaper: "(待补充)",
|
||||||
|
sentences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateParagraphExplanation(options: {
|
||||||
|
paperTitle: string;
|
||||||
|
summary: SummaryJson;
|
||||||
|
chunk: TextChunk;
|
||||||
|
priorContext: string;
|
||||||
|
}): Promise<ParagraphLlmResult> {
|
||||||
|
const summaryBlock = [
|
||||||
|
`问题:${options.summary.problem}`,
|
||||||
|
`方法:${options.summary.method}`,
|
||||||
|
`结果:${options.summary.result}`,
|
||||||
|
`局限:${options.summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const user = `【论文标题】${options.paperTitle}
|
||||||
|
|
||||||
|
【全文摘要】
|
||||||
|
${summaryBlock}
|
||||||
|
|
||||||
|
【前文衔接(最近几段要点,可能为空)】
|
||||||
|
${options.priorContext || "(这是论文开头第一段)"}
|
||||||
|
|
||||||
|
【当前段落 ${options.chunk.id}】
|
||||||
|
${options.chunk.text}
|
||||||
|
|
||||||
|
${FORCE_ZH_USER_REMINDER}
|
||||||
|
|
||||||
|
请输出 JSON。`;
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: PARA_SYSTEM,
|
||||||
|
user,
|
||||||
|
parse: (raw) => parseParagraphJson(raw, options.chunk.text),
|
||||||
|
jsonMode: true,
|
||||||
|
fallback: () => fallbackParagraph(options.chunk),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect if chunk is primarily English for TTS lang selection. */
|
||||||
|
export function isPrimarilyEnglish(text: string): boolean {
|
||||||
|
const letters = text.match(/[a-zA-Z]/g)?.length ?? 0;
|
||||||
|
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||||
|
return letters > cjk * 2 && letters > 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRollingContext(
|
||||||
|
recent: Array<{ translation: string; contextLink: string }>,
|
||||||
|
maxChars = 1200,
|
||||||
|
): string {
|
||||||
|
const parts = recent.map(
|
||||||
|
(p, i) =>
|
||||||
|
`[${i + 1}] ${p.contextLink ? `${p.contextLink} ` : ""}${p.translation.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
let out = parts.join("\n");
|
||||||
|
if (out.length > maxChars) out = out.slice(-maxChars);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredSentences(
|
||||||
|
chunk: TextChunk,
|
||||||
|
llmSentences: Array<{ original: string; translation: string }>,
|
||||||
|
paragraphTranslation: string,
|
||||||
|
): StoredSentence[] {
|
||||||
|
const en = isPrimarilyEnglish(chunk.text);
|
||||||
|
return llmSentences.map((s, orderIndex) => {
|
||||||
|
const zh =
|
||||||
|
s.translation.trim() ||
|
||||||
|
(orderIndex === 0 ? paragraphTranslation.slice(0, 120) : "");
|
||||||
|
return {
|
||||||
|
id: `${chunk.id}-s${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
text: s.original,
|
||||||
|
translation: zh,
|
||||||
|
page: chunk.page,
|
||||||
|
searchText: s.original.slice(0, 80),
|
||||||
|
ttsEnStatus: en ? ("pending" as const) : ("skipped" as const),
|
||||||
|
ttsZhStatus: zh ? ("pending" as const) : ("failed" as const),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStoredParagraph(
|
||||||
|
chunk: TextChunk,
|
||||||
|
llm: ParagraphLlmResult,
|
||||||
|
): StoredParagraph {
|
||||||
|
const en = isPrimarilyEnglish(chunk.text);
|
||||||
|
const sentences = buildStoredSentences(chunk, llm.sentences, llm.translation);
|
||||||
|
return {
|
||||||
|
id: chunk.id,
|
||||||
|
orderIndex: chunk.orderIndex,
|
||||||
|
originalText: chunk.text,
|
||||||
|
translation: llm.translation,
|
||||||
|
contextLink: llm.contextLink,
|
||||||
|
roleInPaper: llm.roleInPaper,
|
||||||
|
page: chunk.page,
|
||||||
|
searchText: chunk.searchText ?? chunk.text.slice(0, 80),
|
||||||
|
sentences,
|
||||||
|
ttsEnStatus: en ? "pending" : "skipped",
|
||||||
|
ttsZhStatus: "pending",
|
||||||
|
genStatus: "ready",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** 伴读/讲解 LLM 输出须为中文,即使原文是英文论文。 */
|
||||||
|
export const FORCE_ZH_EXPLANATION = `语言(强制):
|
||||||
|
- 无论原文是英文、中文或中英混合,JSON 内所有讲解/摘要类字段必须全部使用简体中文,严禁输出整句英文。
|
||||||
|
- 原文为英文时:translation、script 等须先准确译成中文再口语讲解,不得直接复述、改写或总结英文原文。
|
||||||
|
- 专有名词、缩写、符号名可保留英文,但其余讲解正文必须是中文。`;
|
||||||
|
|
||||||
|
/** 用户消息末尾的语言提醒(原文可能是英文段落/句子)。 */
|
||||||
|
export const FORCE_ZH_USER_REMINDER =
|
||||||
|
"【语言】无论上文原文是否为英文,JSON 中所有讲解字段必须全部使用简体中文。";
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { parseJsonLoose } from "../../../llm/jsonParse";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { SummaryJson } from "../../domain/types";
|
||||||
|
import { FORCE_ZH_EXPLANATION, FORCE_ZH_USER_REMINDER } from "./promptRules";
|
||||||
|
import { requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const SENTENCE_SYSTEM = `你是 ChatPapers 论文伴读助手。用户选中论文中的一句原文,需要你解释并用于语音朗读。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块。
|
||||||
|
格式:{"translation":"...","syntaxNote":"...","contextRole":"..."}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- translation:准确中文翻译,并用 30–80 字口语解释该句含义(不要念 LaTeX,公式转述为口语)。
|
||||||
|
- syntaxNote:1–2 句,拆解句法结构或关键术语(英文句为主;中文句可简述逻辑)。
|
||||||
|
- contextRole:1 句,说明该句在段落或全文论证中的作用。
|
||||||
|
- 术语翻译与全文保持一致。
|
||||||
|
|
||||||
|
${FORCE_ZH_EXPLANATION}`;
|
||||||
|
|
||||||
|
export interface SentenceLlmResult {
|
||||||
|
translation: string;
|
||||||
|
syntaxNote: string;
|
||||||
|
contextRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSentenceJson(raw: string): SentenceLlmResult {
|
||||||
|
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
|
||||||
|
const translation = String(parsed.translation || parsed.zh || "").trim();
|
||||||
|
if (!translation) {
|
||||||
|
throw new LlmError("provider", "单句讲解 JSON 缺少 translation 字段。");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
translation,
|
||||||
|
syntaxNote: String(parsed.syntaxNote || parsed.syntax || "").trim(),
|
||||||
|
contextRole: String(parsed.contextRole || parsed.role || parsed.context || "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSentence(selectedText: string): SentenceLlmResult {
|
||||||
|
return {
|
||||||
|
translation: selectedText.slice(0, 200),
|
||||||
|
syntaxNote: "",
|
||||||
|
contextRole: "(模型未能生成上下文说明,请重试或更换模型。)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummaryBlock(summary?: SummaryJson): string {
|
||||||
|
if (!summary) return "(暂无全文摘要)";
|
||||||
|
return [
|
||||||
|
`问题:${summary.problem}`,
|
||||||
|
`方法:${summary.method}`,
|
||||||
|
`结果:${summary.result}`,
|
||||||
|
`局限:${summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSentenceExplanation(options: {
|
||||||
|
paperTitle: string;
|
||||||
|
summary?: SummaryJson;
|
||||||
|
selectedText: string;
|
||||||
|
surroundingContext?: string;
|
||||||
|
}): Promise<SentenceLlmResult> {
|
||||||
|
const user = `【论文标题】${options.paperTitle}
|
||||||
|
|
||||||
|
【全文摘要】
|
||||||
|
${formatSummaryBlock(options.summary)}
|
||||||
|
|
||||||
|
【选区附近上下文(可能为空)】
|
||||||
|
${options.surroundingContext?.trim() || "(无)"}
|
||||||
|
|
||||||
|
【用户选中的句子】
|
||||||
|
${options.selectedText.trim()}
|
||||||
|
|
||||||
|
${FORCE_ZH_USER_REMINDER}
|
||||||
|
|
||||||
|
请输出 JSON。`;
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: SENTENCE_SYSTEM,
|
||||||
|
user,
|
||||||
|
parse: parseSentenceJson,
|
||||||
|
jsonMode: true,
|
||||||
|
fallback: () => fallbackSentence(options.selectedText),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { chatStream } from "../../../llm/client";
|
||||||
|
import { stripMarkdownFence } from "../../../llm/jsonParse";
|
||||||
|
import type { ChatMessage } from "../../../llm/types";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
|
||||||
|
export interface StructuredRequestOptions<T> {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
parse: (raw: string) => T;
|
||||||
|
/** OpenAI-compatible json_object mode when supported */
|
||||||
|
jsonMode?: boolean;
|
||||||
|
maxAttempts?: number;
|
||||||
|
fallback?: () => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLlm(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
jsonMode: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await chatStream({ messages, onDelta: () => {}, jsonMode });
|
||||||
|
} catch (e) {
|
||||||
|
if (!jsonMode) throw e;
|
||||||
|
// Local / older endpoints may reject response_format
|
||||||
|
ztoolkit.log("[ChatPapers] jsonMode rejected, retrying without", e);
|
||||||
|
return chatStream({ messages, onDelta: () => {}, jsonMode: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request structured LLM output with parse retries + optional JSON repair pass.
|
||||||
|
*/
|
||||||
|
export async function requestStructuredOutput<T>(
|
||||||
|
options: StructuredRequestOptions<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const maxAttempts = options.maxAttempts ?? 3;
|
||||||
|
let lastRaw = "";
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
let messages: ChatMessage[];
|
||||||
|
|
||||||
|
if (attempt === 0) {
|
||||||
|
messages = [
|
||||||
|
{ role: "system", content: options.system },
|
||||||
|
{ role: "user", content: options.user },
|
||||||
|
];
|
||||||
|
} else if (attempt === 1) {
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${options.system}\n\n重要:只输出 JSON,不要 markdown 代码块,不要前后解释。`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: options.user },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"你是 JSON 修复助手。根据用户给出的内容输出修复后的合法 JSON。只输出 JSON本身,不要其他文字。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `以下内容解析失败,请输出修复后的 JSON:\n${stripMarkdownFence(lastRaw).slice(0, 14000)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const useJsonMode = options.jsonMode !== false && attempt < 2;
|
||||||
|
lastRaw = await callLlm(messages, useJsonMode);
|
||||||
|
return options.parse(lastRaw);
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e : new Error(String(e));
|
||||||
|
if (e instanceof LlmError && (e as LlmError).code !== "provider") throw e;
|
||||||
|
ztoolkit.log(
|
||||||
|
`[ChatPapers:Lecture] structured output attempt ${attempt + 1} failed`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.fallback) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] using structured output fallback");
|
||||||
|
return options.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (
|
||||||
|
lastError ??
|
||||||
|
new LlmError("provider", "模型多次未能返回可解析的 JSON,请更换模型后重试。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseJsonLoose } from "../../../llm/jsonParse";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/** Coarse paragraph chunks from PDFWorker plain text (MinerU MVP fallback). */
|
||||||
|
|
||||||
|
export interface TextChunk {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
/** 1-based page where this chunk starts (from PDF form-feed breaks). */
|
||||||
|
page?: number;
|
||||||
|
/** Short snippet for PDF in-document search. */
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_CHUNK_CHARS = 200;
|
||||||
|
const MAX_CHUNK_CHARS = 900;
|
||||||
|
const MAX_CHUNKS = 40;
|
||||||
|
|
||||||
|
interface RawPara {
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePara(text: string): string {
|
||||||
|
return text.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraphsWithPages(text: string): RawPara[] {
|
||||||
|
const hasPages = text.includes("\f");
|
||||||
|
const pageBlocks = hasPages ? text.split("\f") : [text];
|
||||||
|
const out: RawPara[] = [];
|
||||||
|
|
||||||
|
for (let pageIndex = 0; pageIndex < pageBlocks.length; pageIndex++) {
|
||||||
|
const pageNum = pageIndex + 1;
|
||||||
|
const paras = pageBlocks[pageIndex]
|
||||||
|
.split(/\n\s*\n+/)
|
||||||
|
.map(normalizePara)
|
||||||
|
.filter((s) => s.length > 40);
|
||||||
|
for (const para of paras) {
|
||||||
|
out.push({ text: para, page: pageNum });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length && text.trim()) {
|
||||||
|
out.push({ text: normalizePara(text).slice(0, MAX_CHUNK_CHARS), page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeParagraphs(paras: RawPara[]): Array<{ text: string; page: number }> {
|
||||||
|
const merged: Array<{ text: string; page: number }> = [];
|
||||||
|
let buffer = "";
|
||||||
|
let bufferPage = 1;
|
||||||
|
|
||||||
|
for (const para of paras) {
|
||||||
|
if (
|
||||||
|
buffer &&
|
||||||
|
(buffer.length + para.text.length > MAX_CHUNK_CHARS ||
|
||||||
|
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
|
||||||
|
) {
|
||||||
|
merged.push({ text: buffer, page: bufferPage });
|
||||||
|
buffer = para.text;
|
||||||
|
bufferPage = para.page;
|
||||||
|
} else {
|
||||||
|
if (!buffer) bufferPage = para.page;
|
||||||
|
buffer = buffer ? `${buffer} ${para.text}` : para.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer) merged.push({ text: buffer, page: bufferPage });
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkPaperText(text: string): TextChunk[] {
|
||||||
|
const paras = extractParagraphsWithPages(text);
|
||||||
|
const merged = mergeParagraphs(paras);
|
||||||
|
|
||||||
|
return merged.slice(0, MAX_CHUNKS).map((chunk, orderIndex) => {
|
||||||
|
const chunkText = chunk.text.slice(0, 2000);
|
||||||
|
return {
|
||||||
|
id: `p-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
text: chunkText,
|
||||||
|
page: chunk.page,
|
||||||
|
searchText: chunkText.slice(0, 80),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact outline for LLM beats generation. */
|
||||||
|
export function formatChunkOutline(chunks: TextChunk[]): string {
|
||||||
|
return chunks
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
`[${c.id}${c.page ? ` p${c.page}` : ""}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lookup chunk metadata by paragraph id. */
|
||||||
|
export function chunkById(
|
||||||
|
chunks: TextChunk[] | undefined,
|
||||||
|
id: string,
|
||||||
|
): TextChunk | undefined {
|
||||||
|
return chunks?.find((c) => c.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* SHA-256 file hash for lecture cache invalidation.
|
||||||
|
* Uses Web Crypto (available in Zotero/Firefox) — works on macOS and Windows.
|
||||||
|
*/
|
||||||
|
export async function computeFileHash(filePath: string): Promise<string> {
|
||||||
|
const data = await IOUtils.read(filePath);
|
||||||
|
const view = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", view);
|
||||||
|
return [...new Uint8Array(digest)]
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCacheKey(attachmentId: string, fileHash: string): string {
|
||||||
|
return `${attachmentId}|${fileHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paperIdFromCacheKey(cacheKey: string): string {
|
||||||
|
// Stable id for JSON store; SQLite POC may use hash of cacheKey
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < cacheKey.length; i++) {
|
||||||
|
hash = (hash * 31 + cacheKey.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return `paper-${Math.abs(hash).toString(36)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Split paragraph text into sentences (EN + CJK punctuation). */
|
||||||
|
|
||||||
|
const MIN_SENTENCE_CHARS = 12;
|
||||||
|
const MAX_SENTENCES = 12;
|
||||||
|
|
||||||
|
export function splitSentences(text: string): string[] {
|
||||||
|
const normalized = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
const parts =
|
||||||
|
normalized.match(/[^.!?。!?]+[.!?。!?]+|[^.!?。!?]+$/g) ?? [normalized];
|
||||||
|
|
||||||
|
const sentences = parts
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length >= MIN_SENTENCE_CHARS);
|
||||||
|
|
||||||
|
if (!sentences.length && normalized.length >= MIN_SENTENCE_CHARS) {
|
||||||
|
return [normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentences.slice(0, MAX_SENTENCES);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { isMac, isWindows } from "./os";
|
||||||
|
import { joinPath, normalizeExecutablePath } from "./paths";
|
||||||
|
|
||||||
|
/** Well-known absolute paths before PATH lookup */
|
||||||
|
const SYSTEM_HINTS: Record<string, string[]> = {
|
||||||
|
say: ["/usr/bin/say"],
|
||||||
|
afconvert: ["/usr/bin/afconvert"],
|
||||||
|
piper: ["/usr/local/bin/piper", "/opt/homebrew/bin/piper"],
|
||||||
|
powershell: [
|
||||||
|
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||||
|
],
|
||||||
|
ffmpeg: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveExecutable(
|
||||||
|
name: string,
|
||||||
|
customPath?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmed = (customPath || "").trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const normalized = normalizeExecutablePath(trimmed);
|
||||||
|
if (await IOUtils.exists(normalized)) return normalized;
|
||||||
|
throw new Error(`可执行文件不存在: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints = SYSTEM_HINTS[name] || [];
|
||||||
|
for (const hint of hints) {
|
||||||
|
try {
|
||||||
|
if (await IOUtils.exists(hint)) return hint;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: bare name (relies on PATH when process starts — may fail in Zotero)
|
||||||
|
return normalizeExecutablePath(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeTempTextFile(text: string, prefix: string): Promise<string> {
|
||||||
|
const dir = PathUtils.tempDir;
|
||||||
|
const path = joinPath(dir, `chatpapers-${prefix}-${Date.now()}.txt`);
|
||||||
|
await IOUtils.writeUTF8(path, text);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeIfExists(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await IOUtils.exists(path)) await IOUtils.remove(path);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultVoiceForLang(lang?: "zh" | "en"): string {
|
||||||
|
if (isMac()) {
|
||||||
|
return lang === "en" ? "Samantha" : "Ting-Ting";
|
||||||
|
}
|
||||||
|
if (isWindows()) {
|
||||||
|
return lang === "en" ? "Microsoft Zira Desktop" : "Microsoft Huihui Desktop";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type HostPlatform = "mac" | "win" | "linux" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve host OS for path / subprocess branching.
|
||||||
|
* Uses Services.appinfo.OS (Darwin | WINNT | Linux | …).
|
||||||
|
*/
|
||||||
|
export function getHostPlatform(): HostPlatform {
|
||||||
|
try {
|
||||||
|
const os = String(Services.appinfo.OS || "").toLowerCase();
|
||||||
|
if (os.includes("darwin")) return "mac";
|
||||||
|
if (os.includes("win")) return "win";
|
||||||
|
if (os.includes("linux")) return "linux";
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWindows(): boolean {
|
||||||
|
return getHostPlatform() === "win";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMac(): boolean {
|
||||||
|
return getHostPlatform() === "mac";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path list separator (`PATH` env): `;` on Windows, `:` elsewhere. */
|
||||||
|
export function pathListSeparator(): string {
|
||||||
|
return isWindows() ? ";" : ":";
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { isWindows } from "./os";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize user-provided executable path for the current OS.
|
||||||
|
* - Expands ~ on macOS/Linux via PathUtils (when available)
|
||||||
|
* - Ensures .exe suffix on Windows when missing
|
||||||
|
*/
|
||||||
|
export function normalizeExecutablePath(input: string): string {
|
||||||
|
const trimmed = (input || "").trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
const path = trimmed;
|
||||||
|
|
||||||
|
if (isWindows() && !/\.(exe|cmd|bat)$/i.test(path)) {
|
||||||
|
return `${path}.exe`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join path segments using Zotero PathUtils (cross-platform). */
|
||||||
|
export function joinPath(...parts: string[]): string {
|
||||||
|
const filtered = parts.filter(Boolean);
|
||||||
|
if (!filtered.length) return "";
|
||||||
|
try {
|
||||||
|
return PathUtils.join(...filtered);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers] PathUtils.join failed", filtered, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize user-provided storage directory paths (prefs). */
|
||||||
|
export function normalizeStoragePath(input: string): string {
|
||||||
|
const trimmed = (input || "").trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
const unified = trimmed.replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||||
|
|
||||||
|
const winMatch = unified.match(/^([A-Za-z]:)(\/.*)?$/);
|
||||||
|
if (winMatch) {
|
||||||
|
const drive = winMatch[1];
|
||||||
|
const rest = (winMatch[2] || "")
|
||||||
|
.split("/")
|
||||||
|
.filter((part) => part && part !== ".")
|
||||||
|
.join("\\");
|
||||||
|
return rest ? `${drive}\\${rest}` : drive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = unified.startsWith("/");
|
||||||
|
const parts = unified.split("/").filter((part) => part && part !== ".");
|
||||||
|
let result = parts.join("/");
|
||||||
|
if (absolute) result = `/${result}`;
|
||||||
|
return result.replace(/\/+$/, "") || (absolute ? "/" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe single path segment for file or directory names (cross-platform). */
|
||||||
|
export function sanitizePathSegment(input: string, maxLen = 120): string {
|
||||||
|
const cleaned = String(input || "")
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
|
||||||
|
.replace(/[.\s]+$/, "")
|
||||||
|
.trim();
|
||||||
|
if (!cleaned) return "_";
|
||||||
|
if (cleaned.length <= maxLen) return cleaned;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = (hash * 31 + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
const tail = Math.abs(hash).toString(36);
|
||||||
|
return `${cleaned.slice(0, Math.max(1, maxLen - tail.length - 1))}_${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert local file path to file:// URL for <audio src>. */
|
||||||
|
export function pathToFileUrl(filePath: string): string {
|
||||||
|
const normalized = filePath.replace(/\\/g, "/");
|
||||||
|
if (normalized.startsWith("file://")) return normalized;
|
||||||
|
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||||
|
return `file:///${encodeURI(normalized)}`;
|
||||||
|
}
|
||||||
|
return `file://${encodeURI(normalized)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { getHostPlatform, isWindows, pathListSeparator } from "./os";
|
||||||
|
import { normalizeExecutablePath } from "./paths";
|
||||||
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
|
||||||
|
export interface RunCommandOptions {
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunCommandResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a platform-aware command argv.
|
||||||
|
* On Windows, wraps `.cmd` / `.bat` via cmd.exe when needed.
|
||||||
|
*/
|
||||||
|
export function buildCommandArgv(
|
||||||
|
executable: string,
|
||||||
|
args: string[],
|
||||||
|
): string[] {
|
||||||
|
const exe = normalizeExecutablePath(executable);
|
||||||
|
if (!exe) throw new Error("Executable path is empty");
|
||||||
|
|
||||||
|
if (isWindows() && /\.(cmd|bat)$/i.test(exe)) {
|
||||||
|
return ["cmd.exe", "/c", exe, ...args];
|
||||||
|
}
|
||||||
|
return [exe, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMinerUCommand(customPath?: string): {
|
||||||
|
executable: string;
|
||||||
|
args: string[];
|
||||||
|
platform: ReturnType<typeof getHostPlatform>;
|
||||||
|
} {
|
||||||
|
const platform = getHostPlatform();
|
||||||
|
const trimmed = (customPath || "").trim();
|
||||||
|
|
||||||
|
if (trimmed) {
|
||||||
|
return { executable: normalizeExecutablePath(trimmed), args: [], platform };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "win") {
|
||||||
|
return { executable: "mineru", args: [], platform };
|
||||||
|
}
|
||||||
|
return { executable: "mineru", args: [], platform };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
|
if (!pathValue) return [];
|
||||||
|
const sep = pathListSeparator();
|
||||||
|
return pathValue.split(sep).map((p) => p.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNsIProcess(): MozNsIProcess {
|
||||||
|
return Components.classes["@mozilla.org/process/util;1"].createInstance(
|
||||||
|
Components.interfaces.nsIProcess,
|
||||||
|
) as MozNsIProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToNsIFile(path: string): MozNsIFile {
|
||||||
|
const file = Zotero.File.pathToFile(path);
|
||||||
|
if (!file?.exists()) {
|
||||||
|
throw new Error(`找不到可执行文件: ${path}`);
|
||||||
|
}
|
||||||
|
if (file.isExecutable && !file.isExecutable()) {
|
||||||
|
throw new Error(`文件不可执行: ${path}`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runViaZoteroExec(
|
||||||
|
exePath: string,
|
||||||
|
procArgs: string[],
|
||||||
|
): Promise<RunCommandResult | undefined> {
|
||||||
|
const exec = (Zotero as any).Utilities?.Internal?.exec;
|
||||||
|
if (typeof exec !== "function") return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exec(exePath, procArgs);
|
||||||
|
return { exitCode: 0, stdout: "", stderr: "" };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(`子进程执行失败 (${exePath}): ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runViaNsIProcess(
|
||||||
|
exePath: string,
|
||||||
|
procArgs: string[],
|
||||||
|
options: RunCommandOptions,
|
||||||
|
): RunCommandResult {
|
||||||
|
const proc = getNsIProcess();
|
||||||
|
proc.init(pathToNsIFile(exePath));
|
||||||
|
proc.startHidden = true;
|
||||||
|
|
||||||
|
if (options.cwd) {
|
||||||
|
try {
|
||||||
|
(proc as any).workingDirectory = pathToNsIFile(options.cwd);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] workingDirectory unsupported", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run()/runw() return void — never treat return value as success flag
|
||||||
|
try {
|
||||||
|
proc.run(true, procArgs, procArgs.length);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(`无法启动子进程 (${exePath}): ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = proc.exitValue ?? 0;
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`子进程退出码 ${code}: ${exePath}`);
|
||||||
|
}
|
||||||
|
return { exitCode: code, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run external command (blocking).
|
||||||
|
* Prefers Zotero.Utilities.Internal.exec; falls back to nsIProcess.run.
|
||||||
|
*/
|
||||||
|
export async function runCommand(
|
||||||
|
executable: string,
|
||||||
|
args: string[],
|
||||||
|
options: RunCommandOptions = {},
|
||||||
|
): Promise<RunCommandResult> {
|
||||||
|
const argv = buildCommandArgv(executable, args);
|
||||||
|
const exePath = argv[0]!;
|
||||||
|
const procArgs = argv.slice(1);
|
||||||
|
|
||||||
|
if (getPref("verboseLog")) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] runCommand", exePath, procArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viaExec = await runViaZoteroExec(exePath, procArgs);
|
||||||
|
if (viaExec) return viaExec;
|
||||||
|
|
||||||
|
return runViaNsIProcess(exePath, procArgs, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
audioDirForPaper,
|
||||||
|
audioFilePath,
|
||||||
|
ensureLectureDirs,
|
||||||
|
} from "./paths";
|
||||||
|
import { pathToFileUrl } from "../platform/paths";
|
||||||
|
|
||||||
|
export async function ensureAudioDir(cacheKey: string): Promise<string> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const dir = audioDirForPaper(cacheKey);
|
||||||
|
await IOUtils.makeDirectory(dir, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUnitAudioPath(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
): string {
|
||||||
|
return audioFilePath(cacheKey, unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function audioFileExists(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const path = audioFilePath(cacheKey, unitId);
|
||||||
|
try {
|
||||||
|
return await IOUtils.exists(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** file:// URL safe for HTML5 audio element */
|
||||||
|
export function audioFileUrl(cacheKey: string, unitId: string): string {
|
||||||
|
return pathToFileUrl(audioFilePath(cacheKey, unitId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { BeatType, SummaryJson } from "../../domain/types";
|
||||||
|
import type { TextChunk } from "../pdf/chunker";
|
||||||
|
import {
|
||||||
|
ensureLectureDirs,
|
||||||
|
lectureDataFilePath,
|
||||||
|
} from "./paths";
|
||||||
|
|
||||||
|
export interface StoredBeat {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
title?: string;
|
||||||
|
type: BeatType;
|
||||||
|
script: string;
|
||||||
|
refs: string[];
|
||||||
|
audioPath?: string;
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredSentence {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
translation: string;
|
||||||
|
page?: number;
|
||||||
|
searchText?: string;
|
||||||
|
audioPathEn?: string;
|
||||||
|
audioPathZh?: string;
|
||||||
|
ttsEnStatus: "pending" | "ready" | "failed" | "skipped";
|
||||||
|
ttsZhStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredParagraph {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
originalText: string;
|
||||||
|
translation: string;
|
||||||
|
contextLink: string;
|
||||||
|
roleInPaper: string;
|
||||||
|
page?: number;
|
||||||
|
searchText?: string;
|
||||||
|
sentences?: StoredSentence[];
|
||||||
|
audioPathEn?: string;
|
||||||
|
audioPathZh?: string;
|
||||||
|
ttsEnStatus: "pending" | "ready" | "failed" | "skipped";
|
||||||
|
ttsZhStatus: "pending" | "ready" | "failed";
|
||||||
|
genStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdHocSelectionExplain {
|
||||||
|
id: string;
|
||||||
|
original: string;
|
||||||
|
translation: string;
|
||||||
|
syntaxNote: string;
|
||||||
|
contextRole: string;
|
||||||
|
page?: number;
|
||||||
|
audioPathEn?: string;
|
||||||
|
audioPathZh?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LectureData {
|
||||||
|
paperId: string;
|
||||||
|
cacheKey: string;
|
||||||
|
/** 1 = summary, 2 = beats+TTS, 3 = paragraphs+note */
|
||||||
|
phase?: 1 | 2 | 3;
|
||||||
|
summary?: SummaryJson;
|
||||||
|
chunks?: TextChunk[];
|
||||||
|
beats?: StoredBeat[];
|
||||||
|
paragraphs?: StoredParagraph[];
|
||||||
|
adHocSelections?: AdHocSelectionExplain[];
|
||||||
|
noteItemId?: string;
|
||||||
|
extractChars?: number;
|
||||||
|
extractSource?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lectureDataPath(cacheKey: string): string {
|
||||||
|
return lectureDataFilePath(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLectureData(
|
||||||
|
cacheKey: string,
|
||||||
|
): Promise<LectureData | undefined> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureDataPath(cacheKey);
|
||||||
|
try {
|
||||||
|
if (!(await IOUtils.exists(path))) return undefined;
|
||||||
|
const raw = await IOUtils.readUTF8(path);
|
||||||
|
return JSON.parse(raw) as LectureData;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] loadLectureData failed", e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLectureData(data: LectureData): Promise<void> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
data.updatedAt = Date.now();
|
||||||
|
await IOUtils.writeUTF8(
|
||||||
|
lectureDataPath(data.cacheKey),
|
||||||
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeLectureData(
|
||||||
|
cacheKey: string,
|
||||||
|
paperId: string,
|
||||||
|
patch: Partial<LectureData>,
|
||||||
|
): Promise<LectureData> {
|
||||||
|
const existing =
|
||||||
|
(await loadLectureData(cacheKey)) ??
|
||||||
|
({
|
||||||
|
paperId,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} satisfies LectureData);
|
||||||
|
const merged: LectureData = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
paperId,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await saveLectureData(merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStep1Ready(data: LectureData | undefined): boolean {
|
||||||
|
if (!data?.beats?.length) return false;
|
||||||
|
return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStep2Ready(data: LectureData | undefined): boolean {
|
||||||
|
if (!data?.paragraphs?.length) return false;
|
||||||
|
return data.paragraphs.some(
|
||||||
|
(p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLectureComplete(data: LectureData | undefined): boolean {
|
||||||
|
return isStep1Ready(data) && isStep2Ready(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playableBeats(data: LectureData | undefined): StoredBeat[] {
|
||||||
|
if (!data?.beats) return [];
|
||||||
|
return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playableParagraphs(
|
||||||
|
data: LectureData | undefined,
|
||||||
|
): StoredParagraph[] {
|
||||||
|
if (!data?.paragraphs) return [];
|
||||||
|
return data.paragraphs.filter(
|
||||||
|
(p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
import { getString } from "../../../../utils/locale";
|
||||||
|
import {
|
||||||
|
joinPath,
|
||||||
|
normalizeStoragePath,
|
||||||
|
sanitizePathSegment,
|
||||||
|
} from "../platform/paths";
|
||||||
|
|
||||||
|
function resolveProfileDir(): string {
|
||||||
|
const dir = (Zotero as any).Profile?.dir;
|
||||||
|
if (typeof dir === "string" && dir.trim()) {
|
||||||
|
return dir.trim();
|
||||||
|
}
|
||||||
|
throw new Error("Cannot resolve Zotero profile directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
function customLectureDataDir(): string {
|
||||||
|
return String(getPref("lectureDataDir") || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map logical cacheKey (may contain `|`) to a filesystem-safe directory/file stem. */
|
||||||
|
export function cacheKeyDirName(cacheKey: string): string {
|
||||||
|
return sanitizePathSegment(cacheKey, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved voice-lecture data root (custom pref or `{Profile}/chatpapers/lecture/`). */
|
||||||
|
export function lectureDataRoot(): string {
|
||||||
|
const custom = customLectureDataDir();
|
||||||
|
if (custom) {
|
||||||
|
return normalizeStoragePath(custom);
|
||||||
|
}
|
||||||
|
return joinPath(resolveProfileDir(), "chatpapers", "lecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureDbPath(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "lecture.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interim JSON index until SQLite POC lands */
|
||||||
|
export function lectureIndexPath(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "cache-index.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureDataFilePath(cacheKey: string): string {
|
||||||
|
return joinPath(lectureDataRoot(), "data", `${cacheKeyDirName(cacheKey)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioRootDir(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioDirForPaper(cacheKey: string): string {
|
||||||
|
return joinPath(audioRootDir(), cacheKeyDirName(cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioFilePath(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
ext: "wav" | "mp3" = "wav",
|
||||||
|
): string {
|
||||||
|
return joinPath(
|
||||||
|
audioDirForPaper(cacheKey),
|
||||||
|
`${sanitizePathSegment(unitId, 80)}.${ext}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureSamplePath(name: string): string {
|
||||||
|
return joinPath(lectureDataRoot(), "samples", sanitizePathSegment(name, 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSampleDir(): Promise<string> {
|
||||||
|
const dir = joinPath(lectureDataRoot(), "samples");
|
||||||
|
await ensureDir(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(path: string): Promise<void> {
|
||||||
|
await IOUtils.makeDirectory(path, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLectureDirs(): Promise<void> {
|
||||||
|
const root = lectureDataRoot();
|
||||||
|
try {
|
||||||
|
await ensureDir(root);
|
||||||
|
await ensureDir(joinPath(root, "data"));
|
||||||
|
await ensureDir(audioRootDir());
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(
|
||||||
|
getString("lecture-storage-write-failed", {
|
||||||
|
args: { path: root, detail },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { PaperCache } from "../../domain/types";
|
||||||
|
import { buildCacheKey } from "../pdf/fileHash";
|
||||||
|
import {
|
||||||
|
ensureLectureDirs,
|
||||||
|
lectureIndexPath,
|
||||||
|
} from "./paths";
|
||||||
|
|
||||||
|
interface IndexFile {
|
||||||
|
schemaVersion: number;
|
||||||
|
/** keyed by cacheKey (attachmentId|fileHash) */
|
||||||
|
papers: Record<string, PaperCache>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
|
function migratePapers(
|
||||||
|
papers: Record<string, PaperCache>,
|
||||||
|
): Record<string, PaperCache> {
|
||||||
|
const out: Record<string, PaperCache> = {};
|
||||||
|
for (const [key, paper] of Object.entries(papers)) {
|
||||||
|
const cacheKey =
|
||||||
|
key.includes("|") && key.includes(paper.fileHash)
|
||||||
|
? key
|
||||||
|
: buildCacheKey(paper.attachmentId, paper.fileHash);
|
||||||
|
out[cacheKey] = paper;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIndex(): Promise<IndexFile> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureIndexPath();
|
||||||
|
try {
|
||||||
|
if (!(await IOUtils.exists(path))) {
|
||||||
|
return { schemaVersion: SCHEMA_VERSION, papers: {} };
|
||||||
|
}
|
||||||
|
const raw = await IOUtils.readUTF8(path);
|
||||||
|
const data = JSON.parse(raw) as IndexFile;
|
||||||
|
const papers = migratePapers(data.papers ?? {});
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
papers,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] readIndex failed", e);
|
||||||
|
return { schemaVersion: SCHEMA_VERSION, papers: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(index: IndexFile): Promise<void> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureIndexPath();
|
||||||
|
await IOUtils.writeUTF8(
|
||||||
|
path,
|
||||||
|
JSON.stringify({ ...index, schemaVersion: SCHEMA_VERSION }, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaperRepository {
|
||||||
|
async getByCacheKey(cacheKey: string): Promise<PaperCache | undefined> {
|
||||||
|
const index = await readIndex();
|
||||||
|
return index.papers[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByAttachment(
|
||||||
|
attachmentId: string,
|
||||||
|
fileHash: string,
|
||||||
|
): Promise<PaperCache | undefined> {
|
||||||
|
const cacheKey = `${attachmentId}|${fileHash}`;
|
||||||
|
return this.getByCacheKey(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(cacheKey: string, paper: PaperCache): Promise<void> {
|
||||||
|
const index = await readIndex();
|
||||||
|
index.papers[cacheKey] = paper;
|
||||||
|
await writeIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(): Promise<PaperCache[]> {
|
||||||
|
const index = await readIndex();
|
||||||
|
return Object.values(index.papers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paperRepository = new PaperRepository();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* SQLite schema for voice lecture mode.
|
||||||
|
* Applied when sql.js / native SQLite backend is confirmed in Phase 0 POC.
|
||||||
|
*/
|
||||||
|
export const LECTURE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS paper_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT,
|
||||||
|
parse_status TEXT NOT NULL,
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
chapter_id TEXT,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY,
|
||||||
|
summary_json TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS progress (
|
||||||
|
paper_id TEXT PRIMARY KEY,
|
||||||
|
last_mode TEXT,
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
anchor_type TEXT NOT NULL,
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS highlight (
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
paragraph_id TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LECTURE_SCHEMA_VERSION = 1;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
import {
|
||||||
|
createTtsProvider,
|
||||||
|
getDefaultTtsProviderId,
|
||||||
|
TTS_PROVIDER_PRESETS,
|
||||||
|
type TtsProviderId,
|
||||||
|
} from "./providers";
|
||||||
|
import type { TtsResult, TtsSynthesizeOptions } from "./types";
|
||||||
|
import { TtsError } from "./types";
|
||||||
|
|
||||||
|
export interface TtsRuntimeConfig {
|
||||||
|
provider: TtsProviderId | string;
|
||||||
|
voice: string;
|
||||||
|
piperPath: string;
|
||||||
|
piperModelPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTtsRuntimeConfig(): TtsRuntimeConfig {
|
||||||
|
const provider =
|
||||||
|
(getPref("ttsProvider") as string) || getDefaultTtsProviderId();
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
voice: (getPref("ttsVoice") as string) || "",
|
||||||
|
piperPath: (getPref("piperPath") as string) || "",
|
||||||
|
piperModelPath: (getPref("piperModelPath") as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTtsProviderLabel(id: string): string {
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id as TtsProviderId];
|
||||||
|
return preset?.label || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synthesizeSpeech(
|
||||||
|
options: TtsSynthesizeOptions,
|
||||||
|
): Promise<TtsResult> {
|
||||||
|
const cfg = getTtsRuntimeConfig();
|
||||||
|
if (!cfg.provider) {
|
||||||
|
throw new TtsError("请先在偏好设置中选择 TTS Provider", "config");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = createTtsProvider(cfg.provider);
|
||||||
|
return await provider.synthesize(options);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TtsError) throw e;
|
||||||
|
throw new TtsError(
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
"runtime",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short sample for prefs / lecture pane smoke test */
|
||||||
|
export const TTS_SAMPLE_ZH =
|
||||||
|
"你好,我是 ChatPapers 语音伴读。这是一段本地语音合成测试。";
|
||||||
|
|
||||||
|
export async function synthesizeSample(outputPath: string): Promise<TtsResult> {
|
||||||
|
return synthesizeSpeech({
|
||||||
|
text: TTS_SAMPLE_ZH,
|
||||||
|
outputPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
defaultVoiceForLang,
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMacSayProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "system-mac",
|
||||||
|
label: "macOS say",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const say = await resolveExecutable("say");
|
||||||
|
const afconvert = await resolveExecutable("afconvert");
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
options.voice ||
|
||||||
|
(getPref("ttsVoice") as string) ||
|
||||||
|
defaultVoiceForLang(options.lang);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
const aiffPath = outWav.replace(/\.wav$/i, ".aiff");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "say-text");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sayResult = await runCommand(say, [
|
||||||
|
"-v",
|
||||||
|
voice,
|
||||||
|
"-f",
|
||||||
|
textPath,
|
||||||
|
"-o",
|
||||||
|
aiffPath,
|
||||||
|
]);
|
||||||
|
if (sayResult.exitCode !== 0) {
|
||||||
|
throw new TtsError(`say 退出码 ${sayResult.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = await runCommand(afconvert, [
|
||||||
|
"-f",
|
||||||
|
"WAVE",
|
||||||
|
"-d",
|
||||||
|
"LEI16",
|
||||||
|
aiffPath,
|
||||||
|
outWav,
|
||||||
|
]);
|
||||||
|
if (conv.exitCode !== 0) {
|
||||||
|
throw new TtsError(`afconvert 退出码 ${conv.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
await removeIfExists(aiffPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { joinPath } from "../../platform/paths";
|
||||||
|
import {
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPiperProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "piper",
|
||||||
|
label: "Piper",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
needsPath: true,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const piperPath = (getPref("piperPath") || "") as string;
|
||||||
|
const modelPath = (getPref("piperModelPath") || "") as string;
|
||||||
|
|
||||||
|
if (!modelPath) {
|
||||||
|
throw new TtsError(
|
||||||
|
"请先在偏好设置 → 语音伴读 中配置 Piper 模型路径(.onnx)",
|
||||||
|
"config",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(modelPath))) {
|
||||||
|
throw new TtsError(`Piper 模型不存在: ${modelPath}`, "config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const piper = await resolveExecutable("piper", piperPath);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "piper-text");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// piper --model model.onnx --output_file out.wav --file input.txt
|
||||||
|
const result = await runCommand(piper, [
|
||||||
|
"--model",
|
||||||
|
modelPath,
|
||||||
|
"--output_file",
|
||||||
|
outWav,
|
||||||
|
"--file",
|
||||||
|
textPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new TtsError(`piper 退出码 ${result.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("Piper 未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggested default model directory hint for prefs UI */
|
||||||
|
export function piperModelHint(): string {
|
||||||
|
return joinPath("{HOME}", ".local", "share", "piper", "models");
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { joinPath } from "../../platform/paths";
|
||||||
|
import {
|
||||||
|
defaultVoiceForLang,
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWindowsSapiProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "system-win",
|
||||||
|
label: "Windows SAPI",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const powershell = await resolveExecutable("powershell");
|
||||||
|
const voice =
|
||||||
|
options.voice ||
|
||||||
|
(getPref("ttsVoice") as string) ||
|
||||||
|
defaultVoiceForLang(options.lang);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "sapi-text");
|
||||||
|
const scriptPath = joinPath(
|
||||||
|
PathUtils.tempDir,
|
||||||
|
`chatpapers-sapi-${Date.now()}.ps1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const psScript = [
|
||||||
|
"Add-Type -AssemblyName System.Speech",
|
||||||
|
"$text = Get-Content -LiteralPath $args[0] -Raw -Encoding UTF8",
|
||||||
|
"$out = $args[1]",
|
||||||
|
"$voice = $args[2]",
|
||||||
|
"$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer",
|
||||||
|
"if ($voice) { try { $synth.SelectVoice($voice) } catch {} }",
|
||||||
|
"$synth.SetOutputToWaveFile($out)",
|
||||||
|
"$synth.Speak($text)",
|
||||||
|
"$synth.Dispose()",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await IOUtils.writeUTF8(scriptPath, psScript);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runCommand(powershell, [
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
scriptPath,
|
||||||
|
textPath,
|
||||||
|
outWav,
|
||||||
|
voice,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new TtsError(
|
||||||
|
`PowerShell SAPI 退出码 ${result.exitCode}`,
|
||||||
|
"runtime",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("SAPI 未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
await removeIfExists(scriptPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { getHostPlatform, isMac, isWindows } from "../platform/os";
|
||||||
|
import type { TtsProvider } from "./types";
|
||||||
|
import { createMacSayProvider } from "./local/macSay";
|
||||||
|
import { createPiperProvider } from "./local/piper";
|
||||||
|
import { createWindowsSapiProvider } from "./local/windowsSapi";
|
||||||
|
|
||||||
|
export interface TtsProviderPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "cloud";
|
||||||
|
description: string;
|
||||||
|
/** Shown when this provider is not available on current OS */
|
||||||
|
platforms?: Array<"mac" | "win" | "linux" | "other">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TTS_PROVIDER_ORDER = [
|
||||||
|
"system",
|
||||||
|
"piper",
|
||||||
|
"openai",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TtsProviderId = (typeof TTS_PROVIDER_ORDER)[number];
|
||||||
|
|
||||||
|
export const TTS_PROVIDER_PRESETS: Record<TtsProviderId, TtsProviderPreset> = {
|
||||||
|
system: {
|
||||||
|
id: "system",
|
||||||
|
label: "本地 · 系统语音",
|
||||||
|
kind: "local",
|
||||||
|
description: "macOS say / Windows SAPI,零配置、完全离线",
|
||||||
|
platforms: ["mac", "win"],
|
||||||
|
},
|
||||||
|
piper: {
|
||||||
|
id: "piper",
|
||||||
|
label: "本地 · Piper",
|
||||||
|
kind: "local",
|
||||||
|
description: "离线神经 TTS,需安装 piper 并配置模型路径",
|
||||||
|
platforms: ["mac", "win", "linux", "other"],
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
id: "openai",
|
||||||
|
label: "云端 · OpenAI 兼容",
|
||||||
|
kind: "cloud",
|
||||||
|
description: "OpenAI / 兼容端点 TTS(待接入)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isTtsProviderAvailable(preset: TtsProviderPreset): boolean {
|
||||||
|
if (!preset.platforms) return true;
|
||||||
|
return preset.platforms.includes(getHostPlatform());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultTtsProviderId(): TtsProviderId {
|
||||||
|
if (isMac() || isWindows()) return "system";
|
||||||
|
return "piper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTtsProvider(id: string): TtsProvider {
|
||||||
|
switch (id) {
|
||||||
|
case "system":
|
||||||
|
if (isMac()) return createMacSayProvider();
|
||||||
|
if (isWindows()) return createWindowsSapiProvider();
|
||||||
|
throw new Error("系统语音在当前平台不可用,请选择 Piper 或云端 TTS");
|
||||||
|
case "piper":
|
||||||
|
return createPiperProvider();
|
||||||
|
case "openai":
|
||||||
|
throw new Error("云端 TTS 尚未接入,请先用本地系统语音或 Piper");
|
||||||
|
default:
|
||||||
|
throw new Error(`未知 TTS Provider: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsSynthesizeOptions {
|
||||||
|
text: string;
|
||||||
|
/** Full output path including extension (.wav / .mp3) */
|
||||||
|
outputPath: string;
|
||||||
|
voice?: string;
|
||||||
|
lang?: "zh" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[];
|
||||||
|
durationMs: number;
|
||||||
|
format: "wav" | "mp3" | "aiff";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsProvider {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "cloud";
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
/** Whether user must configure an executable or model path */
|
||||||
|
needsPath?: boolean;
|
||||||
|
synthesize(options: TtsSynthesizeOptions): Promise<TtsResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TtsError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: "config" | "runtime" | "unsupported",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TtsError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { findPdfAttachment } from "../../../pdf/extractor";
|
||||||
|
import { getString } from "../../../../utils/locale";
|
||||||
|
import type { PaperAttachmentContext } from "../../domain/types";
|
||||||
|
import {
|
||||||
|
buildCacheKey,
|
||||||
|
computeFileHash,
|
||||||
|
paperIdFromCacheKey,
|
||||||
|
} from "../pdf/fileHash";
|
||||||
|
|
||||||
|
async function attachmentFilePath(attachment: Zotero.Item): Promise<string> {
|
||||||
|
const att = attachment as Zotero.Item & {
|
||||||
|
getFilePath?: () => string | false;
|
||||||
|
getFilePathAsync?: () => Promise<string | false>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof att.getFilePathAsync === "function") {
|
||||||
|
try {
|
||||||
|
const asyncPath = await att.getFilePathAsync();
|
||||||
|
if (asyncPath && typeof asyncPath === "string") {
|
||||||
|
return asyncPath;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] getFilePathAsync failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPath = att.getFilePath?.();
|
||||||
|
if (syncPath && typeof syncPath === "string") {
|
||||||
|
return syncPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zAtt = (Zotero as any).Attachments;
|
||||||
|
if (zAtt?.getFilePath) {
|
||||||
|
try {
|
||||||
|
const path = zAtt.getFilePath(attachment);
|
||||||
|
if (path && typeof path === "string") return path;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] Attachments.getFilePath failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("PDF_NOT_LOCAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachmentFileHash(
|
||||||
|
attachment: Zotero.Item,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await computeFileHash(filePath);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] computeFileHash failed", filePath, e);
|
||||||
|
const att = attachment as Zotero.Item & {
|
||||||
|
attachmentHash?: Promise<string | undefined>;
|
||||||
|
};
|
||||||
|
if (att.attachmentHash) {
|
||||||
|
try {
|
||||||
|
const md5 = await att.attachmentHash;
|
||||||
|
if (md5 && typeof md5 === "string") {
|
||||||
|
return `md5:${md5}`;
|
||||||
|
}
|
||||||
|
} catch (hashErr) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] attachmentHash fallback failed", hashErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("PDF_READ_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAttachmentFileReadable(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await IOUtils.exists(filePath)) return;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] IOUtils.exists failed", filePath, e);
|
||||||
|
}
|
||||||
|
throw new Error("PDF_FILE_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLectureAttachmentError(error: unknown): string {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return getString("lecture-attach-error");
|
||||||
|
}
|
||||||
|
switch (error.message) {
|
||||||
|
case "NO_PDF":
|
||||||
|
return getString("lecture-no-pdf");
|
||||||
|
case "PDF_NOT_LOCAL":
|
||||||
|
return getString("lecture-pdf-not-local");
|
||||||
|
case "PDF_FILE_MISSING":
|
||||||
|
return getString("lecture-pdf-file-missing");
|
||||||
|
case "PDF_READ_FAILED":
|
||||||
|
return getString("lecture-pdf-read-failed");
|
||||||
|
default:
|
||||||
|
return getString("lecture-attach-error-detail", {
|
||||||
|
args: { detail: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePaperAttachment(
|
||||||
|
item: Zotero.Item,
|
||||||
|
): Promise<PaperAttachmentContext> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment) {
|
||||||
|
throw new Error("NO_PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath: string;
|
||||||
|
try {
|
||||||
|
filePath = await attachmentFilePath(attachment);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "PDF_NOT_LOCAL") throw e;
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] resolve attachment path failed", {
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
linkMode: (attachment as any).attachmentLinkMode,
|
||||||
|
e,
|
||||||
|
});
|
||||||
|
throw new Error("PDF_NOT_LOCAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureAttachmentFileReadable(filePath);
|
||||||
|
|
||||||
|
const fileHash = await attachmentFileHash(attachment, filePath);
|
||||||
|
const attachmentId = String(attachment.id);
|
||||||
|
const cacheKey = buildCacheKey(attachmentId, fileHash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
attachment,
|
||||||
|
filePath,
|
||||||
|
fileHash,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegularItem(item: Zotero.Item): Zotero.Item {
|
||||||
|
if (item.isRegularItem()) return item;
|
||||||
|
if (item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID);
|
||||||
|
if (parent) return parent;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPdfAtPage(
|
||||||
|
item: Zotero.Item,
|
||||||
|
page: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = await (Zotero.Reader as any).open(attachment.id);
|
||||||
|
if (reader?.navigate) {
|
||||||
|
await reader.navigate({ pageNumber: page });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reader?.setPageIndex) {
|
||||||
|
reader.setPageIndex(Math.max(0, page - 1));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] openPdfAtPage failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { paperIdFromCacheKey };
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { config } from "../../../../package.json";
|
||||||
|
import { getLocaleID, getString } from "../../../utils/locale";
|
||||||
|
import { findPdfAttachment } from "../../pdf/extractor";
|
||||||
|
import { ensureChatPapersStyles } from "../../ui/readerPane";
|
||||||
|
import {
|
||||||
|
applyAdaptiveHeight,
|
||||||
|
applyItemPaneSectionButtonIcons,
|
||||||
|
applyItemPaneSectionHeadLabel,
|
||||||
|
buildItemPaneSectionButtons,
|
||||||
|
clearSectionSummary,
|
||||||
|
onItemPaneSectionToggle,
|
||||||
|
prepareItemPaneBody,
|
||||||
|
refreshSectionOpenHeight,
|
||||||
|
} from "../../ui/itemPaneSection";
|
||||||
|
import { LecturePaneView } from "./lecturePane";
|
||||||
|
|
||||||
|
const views = new WeakMap<HTMLElement, LecturePaneView>();
|
||||||
|
|
||||||
|
function iconURL(file: string) {
|
||||||
|
return `chrome://${config.addonRef}/content/icons/${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLecturePane() {
|
||||||
|
Zotero.ItemPaneManager.registerSection({
|
||||||
|
paneID: "chatpapers-lecture",
|
||||||
|
pluginID: config.addonID,
|
||||||
|
header: {
|
||||||
|
l10nID: getLocaleID("item-section-lecture-head"),
|
||||||
|
icon: iconURL("lecture.svg"),
|
||||||
|
},
|
||||||
|
sidenav: {
|
||||||
|
l10nID: getLocaleID("item-section-lecture-sidenav"),
|
||||||
|
icon: iconURL("lecture.svg"),
|
||||||
|
},
|
||||||
|
sectionButtons: buildItemPaneSectionButtons("lecture"),
|
||||||
|
onInit: ({ body }) => {
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (doc) ensureChatPapersStyles(doc);
|
||||||
|
prepareItemPaneBody(body);
|
||||||
|
applyAdaptiveHeight(body, "lecture");
|
||||||
|
applyItemPaneSectionHeadLabel(body, "lecture");
|
||||||
|
},
|
||||||
|
onDestroy: ({ body }) => {
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
views.delete(body);
|
||||||
|
},
|
||||||
|
onItemChange: ({ item, setEnabled }) => {
|
||||||
|
setEnabled(Boolean(item && findPdfAttachment(item)));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onRender: ({ body, setSectionSummary }) => {
|
||||||
|
body.replaceChildren();
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
applyItemPaneSectionHeadLabel(body, "lecture");
|
||||||
|
clearSectionSummary(body, setSectionSummary);
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
|
const loading = doc.createElement("div");
|
||||||
|
loading.className = "chatpapers-loading";
|
||||||
|
loading.textContent = getString("lecture-loading");
|
||||||
|
body.append(loading);
|
||||||
|
},
|
||||||
|
onAsyncRender: async ({ body, item, setSectionSummary }) => {
|
||||||
|
if (!item) return;
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
const view = new LecturePaneView(doc, body, item);
|
||||||
|
views.set(body, view);
|
||||||
|
try {
|
||||||
|
await view.mount();
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] mount failed", e);
|
||||||
|
body.replaceChildren();
|
||||||
|
const err = doc.createElement("div");
|
||||||
|
err.className = "chatpapers-lecture-error";
|
||||||
|
err.textContent = getString("lecture-mount-error");
|
||||||
|
body.append(err);
|
||||||
|
}
|
||||||
|
clearSectionSummary(body, setSectionSummary);
|
||||||
|
applyItemPaneSectionHeadLabel(body, "lecture");
|
||||||
|
applyAdaptiveHeight(body, "lecture");
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
|
const section = body.closest("collapsible-section");
|
||||||
|
if (section?.hasAttribute("open")) {
|
||||||
|
refreshSectionOpenHeight(body);
|
||||||
|
} else if (section) {
|
||||||
|
onItemPaneSectionToggle({ body });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggle: ({ body, event }) => {
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
|
onItemPaneSectionToggle({ body, event });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { config } from "../../../../package.json";
|
||||||
|
import { getString } from "../../../utils/locale";
|
||||||
|
import { cacheReaderSelection, getCachedReaderSelectionPosition } from "../../pdf/selection";
|
||||||
|
import { highlightTextInPdf } from "../../pdf/readerSync";
|
||||||
|
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
|
||||||
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import { explainSelectionLecture } from "../application/explainSelectionLecture";
|
||||||
|
import {
|
||||||
|
beginSelectionExplainSession,
|
||||||
|
isSelectionExplainSessionActive,
|
||||||
|
playSelectionExplainExclusive,
|
||||||
|
} from "../application/playSelectionExplain";
|
||||||
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
|
|
||||||
|
function tryString(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFromReader(reader: any): Zotero.Item | undefined {
|
||||||
|
const attachmentId = reader?.itemID ?? reader?._itemID;
|
||||||
|
if (!attachmentId) return undefined;
|
||||||
|
const attachment = Zotero.Items.get(attachmentId);
|
||||||
|
if (!attachment) return undefined;
|
||||||
|
return getRegularItem(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHeadline(text: string): void {
|
||||||
|
try {
|
||||||
|
const pw = new Zotero.ProgressWindow({ closeOnClick: true });
|
||||||
|
pw.changeHeadline(text);
|
||||||
|
pw.show();
|
||||||
|
pw.startCloseTimer(4000);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelectionExplainFromReader(
|
||||||
|
reader: any,
|
||||||
|
selectedText: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const text = tryString(selectedText);
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const item = itemFromReader(reader);
|
||||||
|
if (!item) {
|
||||||
|
showHeadline(getString("lecture-selection-no-item"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheReaderSelection(text);
|
||||||
|
const sessionId = beginSelectionExplainSession();
|
||||||
|
showHeadline(getString("lecture-selection-explaining"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = await resolvePaperAttachment(item);
|
||||||
|
const result = await explainSelectionLecture({
|
||||||
|
item,
|
||||||
|
selectedText: text,
|
||||||
|
onProgress: (message) => showHeadline(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||||
|
|
||||||
|
await highlightTextInPdf(item, {
|
||||||
|
text: result.original,
|
||||||
|
page: result.page,
|
||||||
|
position: getCachedReaderSelectionPosition(result.original),
|
||||||
|
});
|
||||||
|
|
||||||
|
lectureEvents.emit("lecture:selection_explain", {
|
||||||
|
paperId: paperIdFromCacheKey(ctx.cacheKey),
|
||||||
|
cacheKey: ctx.cacheKey,
|
||||||
|
itemId: String(getRegularItem(item).id),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||||
|
|
||||||
|
await playSelectionExplainExclusive(result);
|
||||||
|
showHeadline(getString("lecture-selection-done"));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] selection explain failed", e);
|
||||||
|
showHeadline(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSelectionExplainPopup(): void {
|
||||||
|
try {
|
||||||
|
Zotero.Reader.registerEventListener(
|
||||||
|
"renderTextSelectionPopup",
|
||||||
|
(event: any) => {
|
||||||
|
try {
|
||||||
|
const { doc, params, append, reader } = event;
|
||||||
|
const text =
|
||||||
|
tryString(params?.annotation?.text) || tryString(params?.text);
|
||||||
|
if (!text) return;
|
||||||
|
cacheReaderSelection(text);
|
||||||
|
|
||||||
|
const btn = doc.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className =
|
||||||
|
"toolbar-button wide-button chatpapers-selection-explain-btn";
|
||||||
|
btn.textContent = getString("lecture-explain-selection-short");
|
||||||
|
btn.title = getString("lecture-explain-selection");
|
||||||
|
btn.addEventListener("click", (ev: Event) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
void runSelectionExplainFromReader(reader, text);
|
||||||
|
}, { once: true });
|
||||||
|
append(btn);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config.addonID,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] registerSelectionExplainPopup failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,19 +149,24 @@ export async function chatStream(options: {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
onDelta: (text: string) => void;
|
onDelta: (text: string) => void;
|
||||||
signal?: AbortSignalLike;
|
signal?: AbortSignalLike;
|
||||||
|
/** OpenAI-compatible response_format json_object */
|
||||||
|
jsonMode?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const cfg = getRuntimeConfig();
|
const cfg = getRuntimeConfig();
|
||||||
assertConfig(cfg);
|
assertConfig(cfg);
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
const url = joinUrl(cfg.baseUrl, "/chat/completions");
|
const url = joinUrl(cfg.baseUrl, "/chat/completions");
|
||||||
const body = {
|
const body: Record<string, unknown> = {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
messages: options.messages,
|
messages: options.messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
temperature: cfg.temperature,
|
temperature: cfg.temperature,
|
||||||
max_tokens: cfg.maxTokens,
|
max_tokens: cfg.maxTokens,
|
||||||
};
|
};
|
||||||
|
if (options.jsonMode) {
|
||||||
|
body.response_format = { type: "json_object" };
|
||||||
|
}
|
||||||
|
|
||||||
const doFetch = resolveFetch();
|
const doFetch = resolveFetch();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { LlmError } from "./types";
|
||||||
|
|
||||||
|
/** Remove markdown fences and leading/trailing prose wrappers. */
|
||||||
|
export function stripMarkdownFence(text: string): string {
|
||||||
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced) return fenced[1].trim();
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract first balanced `{...}` or `[...]` substring. */
|
||||||
|
export function extractBalancedJson(
|
||||||
|
text: string,
|
||||||
|
open: "{" | "[",
|
||||||
|
): string | undefined {
|
||||||
|
const start = text.indexOf(open);
|
||||||
|
if (start < 0) return undefined;
|
||||||
|
const close = open === "{" ? "}" : "]";
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escape) escape = false;
|
||||||
|
else if (c === "\\") escape = true;
|
||||||
|
else if (c === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === open) depth++;
|
||||||
|
if (c === close) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fix common LLM JSON mistakes (not a full repair engine). */
|
||||||
|
export function repairJsonText(json: string): string {
|
||||||
|
return json
|
||||||
|
.replace(/[\u201c\u201d\u201e]/g, '"')
|
||||||
|
.replace(/[\u2018\u2019]/g, "'")
|
||||||
|
.replace(/,\s*([}\]])/g, "$1")
|
||||||
|
.replace(/\r\n/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParse<T>(text: string): T | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort JSON parse for LLM outputs.
|
||||||
|
* Tries: direct → fenced → balanced extract → repaired variants.
|
||||||
|
*/
|
||||||
|
export function parseJsonLoose<T>(raw: string): T {
|
||||||
|
const trimmed = stripMarkdownFence(raw);
|
||||||
|
const attempts: string[] = [trimmed];
|
||||||
|
|
||||||
|
const obj = extractBalancedJson(trimmed, "{");
|
||||||
|
const arr = extractBalancedJson(trimmed, "[");
|
||||||
|
if (obj) attempts.push(obj, repairJsonText(obj));
|
||||||
|
if (arr) attempts.push(arr, repairJsonText(arr));
|
||||||
|
attempts.push(repairJsonText(trimmed));
|
||||||
|
|
||||||
|
for (const candidate of attempts) {
|
||||||
|
const parsed = tryParse<T>(candidate);
|
||||||
|
if (parsed !== undefined) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LlmError(
|
||||||
|
"provider",
|
||||||
|
"模型返回的内容无法解析为 JSON。可尝试更换模型,或降低 temperature。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `key: value` / `key:value` labeled blocks (Chinese labels). */
|
||||||
|
export function parseLabeledFields(
|
||||||
|
text: string,
|
||||||
|
keys: Array<{ field: string; labels: string[] }>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const lines = text.split(/\n/);
|
||||||
|
let currentField: string | undefined;
|
||||||
|
let buffer: string[] = [];
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (currentField && buffer.length) {
|
||||||
|
out[currentField] = buffer.join(" ").trim();
|
||||||
|
}
|
||||||
|
buffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
let matched = false;
|
||||||
|
for (const { field, labels } of keys) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const re = new RegExp(`^\\s*${label}\\s*[::]\\s*(.*)$`, "i");
|
||||||
|
const m = line.match(re);
|
||||||
|
if (m) {
|
||||||
|
flush();
|
||||||
|
currentField = field;
|
||||||
|
if (m[1]?.trim()) buffer.push(m[1].trim());
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) break;
|
||||||
|
}
|
||||||
|
if (!matched && currentField && line.trim()) {
|
||||||
|
buffer.push(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
/**
|
||||||
|
* Sync lecture playback to Zotero PDF reader: navigate + multi-rect highlight.
|
||||||
|
* Uses internal reader APIs (best-effort; may break across Zotero versions).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findPdfAttachment } from "./extractor";
|
||||||
|
import {
|
||||||
|
getCachedReaderSelectionPosition,
|
||||||
|
normalizeHighlightText,
|
||||||
|
} from "./selection";
|
||||||
|
|
||||||
|
export interface PdfPosition {
|
||||||
|
pageIndex: number;
|
||||||
|
rects?: number[][];
|
||||||
|
nextPageRects?: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInternalReader(reader: any): any {
|
||||||
|
if (!reader) return undefined;
|
||||||
|
try {
|
||||||
|
if (reader._internalReader) return reader._internalReader;
|
||||||
|
const wrapped =
|
||||||
|
reader._iframeWindow?.wrappedJSObject?._reader ||
|
||||||
|
reader._iframeWindow?.wrappedJSObject?.reader;
|
||||||
|
if (wrapped) return wrapped;
|
||||||
|
} catch {
|
||||||
|
// ignore cross-origin
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryView(reader: any): any {
|
||||||
|
const internal = getInternalReader(reader);
|
||||||
|
return (
|
||||||
|
internal?._activePrimaryView ??
|
||||||
|
internal?._primaryView ??
|
||||||
|
internal?._lastView
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReaderForAttachment(attachment: Zotero.Item): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await (Zotero.Reader as any).open(attachment.id);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] open failed", e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findReaderForAttachment(attachmentId: number): any {
|
||||||
|
try {
|
||||||
|
const readers =
|
||||||
|
(Zotero.Reader as any).getAll?.() ||
|
||||||
|
(Zotero.Reader as any)._readers ||
|
||||||
|
[];
|
||||||
|
return readers.find(
|
||||||
|
(r: any) => Number(r.itemID ?? r._itemID) === Number(attachmentId),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a short phrase suitable for PDF in-document search. */
|
||||||
|
export function buildPdfSearchQuery(text: string, maxLen = 72): string {
|
||||||
|
const normalized = normalizeHighlightText(text);
|
||||||
|
if (!normalized) return "";
|
||||||
|
if (normalized.length <= maxLen) return normalized;
|
||||||
|
const snippet = normalized.slice(0, maxLen);
|
||||||
|
const lastSpace = snippet.lastIndexOf(" ");
|
||||||
|
return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimQuery(text: string, maxLen: number): string {
|
||||||
|
const normalized = normalizeHighlightText(text);
|
||||||
|
if (!normalized) return "";
|
||||||
|
if (normalized.length <= maxLen) return normalized;
|
||||||
|
const snippet = normalized.slice(0, maxLen);
|
||||||
|
const lastSpace = snippet.lastIndexOf(" ");
|
||||||
|
return lastSpace > 24 ? snippet.slice(0, lastSpace) : snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer full text first — PDF page content joins line breaks as spaces. */
|
||||||
|
function buildQueryCandidates(text: string): string[] {
|
||||||
|
const normalized = normalizeHighlightText(text);
|
||||||
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
const push = (q: string) => {
|
||||||
|
const t = q.trim();
|
||||||
|
if (t.length >= 6) out.push(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(trimQuery(normalized, 480));
|
||||||
|
push(trimQuery(normalized, 320));
|
||||||
|
push(trimQuery(normalized, 220));
|
||||||
|
push(trimQuery(normalized, 140));
|
||||||
|
push(buildPdfSearchQuery(normalized, 96));
|
||||||
|
push(buildPdfSearchQuery(normalized, 72));
|
||||||
|
|
||||||
|
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
|
||||||
|
if (words.length >= 12) push(words.slice(0, 14).join(" "));
|
||||||
|
if (words.length >= 8) push(words.slice(0, 10).join(" "));
|
||||||
|
if (words.length >= 5) push(words.slice(0, 6).join(" "));
|
||||||
|
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split wrapped sentences into line-sized chunks for fallback per-line find. */
|
||||||
|
function splitLineSegments(text: string): string[] {
|
||||||
|
const normalized = normalizeHighlightText(text);
|
||||||
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
const explicitLines = text
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) => normalizeHighlightText(line))
|
||||||
|
.filter((line) => line.length >= 6);
|
||||||
|
|
||||||
|
const source = explicitLines.length > 1 ? explicitLines : [normalized];
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
for (const line of source) {
|
||||||
|
if (line.length <= 88) {
|
||||||
|
segments.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const words = line.split(/\s+/).filter(Boolean);
|
||||||
|
let buf: string[] = [];
|
||||||
|
let len = 0;
|
||||||
|
for (const word of words) {
|
||||||
|
const nextLen = len ? len + 1 + word.length : word.length;
|
||||||
|
if (nextLen > 72 && buf.length) {
|
||||||
|
segments.push(buf.join(" "));
|
||||||
|
buf = [word];
|
||||||
|
len = word.length;
|
||||||
|
} else {
|
||||||
|
buf.push(word);
|
||||||
|
len = nextLen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf.length) segments.push(buf.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(segments.filter((s) => s.length >= 6))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneIntoFrame<T>(value: T, win: Window | undefined): T {
|
||||||
|
if (!win) return value;
|
||||||
|
try {
|
||||||
|
const utils = (Components as any)?.utils;
|
||||||
|
if (utils?.cloneInto) return utils.cloneInto(value, win);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionRectCount(position?: PdfPosition | null): number {
|
||||||
|
if (!position) return 0;
|
||||||
|
return (position.rects?.length ?? 0) + (position.nextPageRects?.length ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePositions(primary: PdfPosition, extra: PdfPosition): PdfPosition {
|
||||||
|
const merged: PdfPosition = {
|
||||||
|
pageIndex: primary.pageIndex,
|
||||||
|
rects: [...(primary.rects ?? [])],
|
||||||
|
};
|
||||||
|
if (primary.nextPageRects?.length) {
|
||||||
|
merged.nextPageRects = [...primary.nextPageRects];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.pageIndex === primary.pageIndex) {
|
||||||
|
merged.rects!.push(...(extra.rects ?? []));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
if (extra.pageIndex === primary.pageIndex + 1) {
|
||||||
|
merged.nextPageRects = [
|
||||||
|
...(merged.nextPageRects ?? []),
|
||||||
|
...(extra.rects ?? []),
|
||||||
|
...(extra.nextPageRects ?? []),
|
||||||
|
];
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
if (extra.pageIndex === primary.pageIndex - 1) {
|
||||||
|
return {
|
||||||
|
pageIndex: extra.pageIndex,
|
||||||
|
rects: [...(extra.rects ?? []), ...(merged.rects ?? [])],
|
||||||
|
nextPageRects: merged.nextPageRects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReaderReady(reader: any): Promise<void> {
|
||||||
|
if (reader?._initPromise) {
|
||||||
|
try {
|
||||||
|
await reader._initPromise;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] reader init failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const internal = getInternalReader(reader);
|
||||||
|
if (internal?._updateState || getPrimaryView(reader)?.setFindState) return;
|
||||||
|
await Zotero.Promise.delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateReader(reader: any, page?: number): Promise<void> {
|
||||||
|
if (!page || page < 1) return;
|
||||||
|
const location = { pageIndex: page - 1, pageNumber: page };
|
||||||
|
try {
|
||||||
|
if (typeof reader?.navigate === "function") {
|
||||||
|
await reader.navigate(location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const internal = getInternalReader(reader);
|
||||||
|
if (typeof internal?.navigate === "function") {
|
||||||
|
await internal.navigate(location);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] navigate failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFindState(query: string) {
|
||||||
|
return {
|
||||||
|
popupOpen: false,
|
||||||
|
active: true,
|
||||||
|
query,
|
||||||
|
highlightAll: true,
|
||||||
|
caseSensitive: false,
|
||||||
|
entireWord: false,
|
||||||
|
index: 0,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFindHighlight(reader: any, query: string): boolean {
|
||||||
|
const internal = getInternalReader(reader);
|
||||||
|
const findState = makeFindState(query);
|
||||||
|
const payload = { primaryViewFindState: findState };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (internal?._updateState) {
|
||||||
|
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||||
|
try {
|
||||||
|
internal.findNext?.(true);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] _updateState find failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = getPrimaryView(reader);
|
||||||
|
if (view?.setFindState) {
|
||||||
|
view.setFindState(findState);
|
||||||
|
try {
|
||||||
|
view.findNext?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] view setFindState failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFindHighlight(reader: any): void {
|
||||||
|
const cleared = makeFindState("");
|
||||||
|
cleared.active = false;
|
||||||
|
const payload = { primaryViewFindState: cleared };
|
||||||
|
try {
|
||||||
|
const internal = getInternalReader(reader);
|
||||||
|
if (internal?._updateState) {
|
||||||
|
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getPrimaryView(reader)?.setFindState?.(cleared);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] clear find failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPositionHighlight(reader: any): void {
|
||||||
|
const view = getPrimaryView(reader);
|
||||||
|
if (!view) return;
|
||||||
|
try {
|
||||||
|
view._readAloudHighlightedPosition = null;
|
||||||
|
view._readAloudSentenceHighlightedPosition = null;
|
||||||
|
view._highlightedPosition = null;
|
||||||
|
view._render?.();
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] clear position highlight failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPositionHighlight(reader: any, position: PdfPosition): boolean {
|
||||||
|
const view = getPrimaryView(reader);
|
||||||
|
if (!view || !positionRectCount(position)) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof view.navigateToPosition === "function") {
|
||||||
|
void view.navigateToPosition(position, {
|
||||||
|
ifNeeded: true,
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view._readAloudHighlightedPosition = position;
|
||||||
|
view._readAloudSentenceHighlightedPosition = null;
|
||||||
|
view._highlightedPosition = null;
|
||||||
|
view._render?.();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] applyPositionHighlight failed", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePdfPosition(raw: any, pageHint?: number): PdfPosition | undefined {
|
||||||
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
|
const pageIndex =
|
||||||
|
typeof raw.pageIndex === "number"
|
||||||
|
? raw.pageIndex
|
||||||
|
: pageHint && pageHint > 0
|
||||||
|
? pageHint - 1
|
||||||
|
: undefined;
|
||||||
|
if (pageIndex == null || pageIndex < 0) return undefined;
|
||||||
|
|
||||||
|
const rects = Array.isArray(raw.rects)
|
||||||
|
? raw.rects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
|
||||||
|
: undefined;
|
||||||
|
const nextPageRects = Array.isArray(raw.nextPageRects)
|
||||||
|
? raw.nextPageRects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!rects?.length && !nextPageRects?.length) return undefined;
|
||||||
|
return {
|
||||||
|
pageIndex,
|
||||||
|
rects: rects?.length ? rects : undefined,
|
||||||
|
nextPageRects: nextPageRects?.length ? nextPageRects : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestMatchPosition(
|
||||||
|
positions: PdfPosition[],
|
||||||
|
preferredPageIndex?: number,
|
||||||
|
): PdfPosition | undefined {
|
||||||
|
if (!positions.length) return undefined;
|
||||||
|
|
||||||
|
let best = positions[0];
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const pos of positions) {
|
||||||
|
let score = positionRectCount(pos) * 10;
|
||||||
|
if (
|
||||||
|
preferredPageIndex != null &&
|
||||||
|
pos.pageIndex === preferredPageIndex - 1
|
||||||
|
) {
|
||||||
|
score += 100;
|
||||||
|
}
|
||||||
|
if (pos.nextPageRects?.length) score += 5;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFindPosition(
|
||||||
|
view: any,
|
||||||
|
pageHint?: number,
|
||||||
|
): Promise<PdfPosition | undefined> {
|
||||||
|
const preferredPageIndex = pageHint && pageHint > 0 ? pageHint : undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const fromResult = normalizePdfPosition(
|
||||||
|
view?._findState?.result?.annotation?.position,
|
||||||
|
preferredPageIndex,
|
||||||
|
);
|
||||||
|
if (fromResult && positionRectCount(fromResult) > 0) {
|
||||||
|
return fromResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageIndexes = new Set<number>();
|
||||||
|
if (preferredPageIndex) pageIndexes.add(preferredPageIndex - 1);
|
||||||
|
const current =
|
||||||
|
view?._iframeWindow?.PDFViewerApplication?.pdfViewer?.currentPageNumber;
|
||||||
|
if (typeof current === "number" && current > 0) {
|
||||||
|
pageIndexes.add(current - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected: PdfPosition[] = [];
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const matches =
|
||||||
|
(await view?._findController?.getMatchPositionsAsync?.(pageIndex)) ??
|
||||||
|
[];
|
||||||
|
for (const match of matches) {
|
||||||
|
const pos = normalizePdfPosition(match, pageIndex + 1);
|
||||||
|
if (pos) collected.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const best = pickBestMatchPosition(collected, preferredPageIndex);
|
||||||
|
if (best) return best;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
await Zotero.Promise.delay(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTextPosition(
|
||||||
|
reader: any,
|
||||||
|
text: string,
|
||||||
|
pageHint?: number,
|
||||||
|
): Promise<PdfPosition | undefined> {
|
||||||
|
const view = getPrimaryView(reader);
|
||||||
|
if (!view) return undefined;
|
||||||
|
|
||||||
|
const candidates = buildQueryCandidates(text);
|
||||||
|
for (const query of candidates) {
|
||||||
|
if (!applyFindHighlight(reader, query)) continue;
|
||||||
|
const position = await waitForFindPosition(view, pageHint);
|
||||||
|
if (position && positionRectCount(position) > 0) {
|
||||||
|
ztoolkit.log(
|
||||||
|
"[ChatPapers:ReaderSync] matched query",
|
||||||
|
query.slice(0, 48),
|
||||||
|
"rects=",
|
||||||
|
positionRectCount(position),
|
||||||
|
);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = splitLineSegments(text);
|
||||||
|
if (segments.length <= 1) return undefined;
|
||||||
|
|
||||||
|
let merged: PdfPosition | undefined;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!applyFindHighlight(reader, segment)) continue;
|
||||||
|
const position = await waitForFindPosition(view, pageHint);
|
||||||
|
if (!position) continue;
|
||||||
|
merged = merged ? mergePositions(merged, position) : position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged && positionRectCount(merged) > 0) {
|
||||||
|
ztoolkit.log(
|
||||||
|
"[ChatPapers:ReaderSync] merged segment highlight rects=",
|
||||||
|
positionRectCount(merged),
|
||||||
|
);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfHighlightTarget {
|
||||||
|
text: string;
|
||||||
|
page?: number;
|
||||||
|
position?: PdfPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open PDF (if needed), jump to page, and highlight matching text. */
|
||||||
|
export async function highlightTextInPdf(
|
||||||
|
item: Zotero.Item,
|
||||||
|
target: PdfHighlightTarget,
|
||||||
|
): Promise<void> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment || !target.text?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let reader = findReaderForAttachment(attachment.id);
|
||||||
|
if (!reader) {
|
||||||
|
reader = await openReaderForAttachment(attachment);
|
||||||
|
}
|
||||||
|
if (!reader) return;
|
||||||
|
|
||||||
|
await waitForReaderReady(reader);
|
||||||
|
await navigateReader(reader, target.page);
|
||||||
|
await Zotero.Promise.delay(250);
|
||||||
|
|
||||||
|
const cachedPosition = getCachedReaderSelectionPosition(target.text);
|
||||||
|
const position =
|
||||||
|
normalizePdfPosition(target.position, target.page) ??
|
||||||
|
normalizePdfPosition(cachedPosition, target.page) ??
|
||||||
|
(await resolveTextPosition(reader, target.text, target.page));
|
||||||
|
|
||||||
|
if (position && applyPositionHighlight(reader, position)) {
|
||||||
|
applyFindHighlight(
|
||||||
|
reader,
|
||||||
|
buildQueryCandidates(target.text)[0] ?? buildPdfSearchQuery(target.text, 96),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackQuery = buildQueryCandidates(target.text)[0];
|
||||||
|
if (fallbackQuery && applyFindHighlight(reader, fallbackQuery)) {
|
||||||
|
ztoolkit.log(
|
||||||
|
"[ChatPapers:ReaderSync] find-only fallback:",
|
||||||
|
fallbackQuery.slice(0, 48),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] all highlight attempts failed");
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove in-document search highlight from the open reader. */
|
||||||
|
export async function clearPdfHighlight(item: Zotero.Item): Promise<void> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment) return;
|
||||||
|
try {
|
||||||
|
const reader = findReaderForAttachment(attachment.id);
|
||||||
|
if (!reader) return;
|
||||||
|
clearFindHighlight(reader);
|
||||||
|
clearPositionHighlight(reader);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,11 +33,16 @@ function textFromReaderInternals(reader: any): string {
|
|||||||
|
|
||||||
// zotero-plugin-toolkit path: selection popup annotation
|
// zotero-plugin-toolkit path: selection popup annotation
|
||||||
try {
|
try {
|
||||||
const popupText =
|
const popup =
|
||||||
reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ??
|
reader?._internalReader?._lastView?._selectionPopup ??
|
||||||
reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text;
|
reader?._internalReader?._primaryView?._selectionPopup;
|
||||||
|
const popupText = popup?.annotation?.text;
|
||||||
const t = tryString(popupText);
|
const t = tryString(popupText);
|
||||||
if (t) return t;
|
if (t) {
|
||||||
|
const position = normalizePdfSelectionPosition(popup?.annotation?.position);
|
||||||
|
if (position) cacheReaderSelection(t, position);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -70,7 +75,13 @@ function textFromReaderInternals(reader: any): string {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const t = tryString(view._selectionPopup?.annotation?.text);
|
const t = tryString(view._selectionPopup?.annotation?.text);
|
||||||
if (t) return t;
|
if (t) {
|
||||||
|
const position = normalizePdfSelectionPosition(
|
||||||
|
view._selectionPopup?.annotation?.position,
|
||||||
|
);
|
||||||
|
if (position) cacheReaderSelection(t, position);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -139,15 +150,83 @@ function listCandidateReaders(): any[] {
|
|||||||
|
|
||||||
/** Last non-empty selection cached while user selects in the reader. */
|
/** Last non-empty selection cached while user selects in the reader. */
|
||||||
let cachedSelection = "";
|
let cachedSelection = "";
|
||||||
|
let cachedSelectionPosition: PdfSelectionPosition | undefined;
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
||||||
export function cacheReaderSelection(text: string) {
|
export interface PdfSelectionPosition {
|
||||||
|
pageIndex: number;
|
||||||
|
rects?: number[][];
|
||||||
|
nextPageRects?: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHighlightText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[\u0000-\u001f\f]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePdfSelectionPosition(raw: unknown): PdfSelectionPosition | undefined {
|
||||||
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
|
const pos = raw as Record<string, unknown>;
|
||||||
|
const pageIndex =
|
||||||
|
typeof pos.pageIndex === "number"
|
||||||
|
? pos.pageIndex
|
||||||
|
: typeof pos.pageNumber === "number"
|
||||||
|
? (pos.pageNumber as number) - 1
|
||||||
|
: undefined;
|
||||||
|
if (pageIndex == null || pageIndex < 0) return undefined;
|
||||||
|
|
||||||
|
const rects = Array.isArray(pos.rects)
|
||||||
|
? pos.rects.filter(
|
||||||
|
(r): r is number[] => Array.isArray(r) && r.length === 4,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const nextPageRects = Array.isArray(pos.nextPageRects)
|
||||||
|
? pos.nextPageRects.filter(
|
||||||
|
(r): r is number[] => Array.isArray(r) && r.length === 4,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!rects?.length && !nextPageRects?.length) return undefined;
|
||||||
|
return {
|
||||||
|
pageIndex,
|
||||||
|
rects: rects?.length ? rects : undefined,
|
||||||
|
nextPageRects: nextPageRects?.length ? nextPageRects : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheReaderSelection(text: string, position?: PdfSelectionPosition) {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
const sameText =
|
||||||
|
normalizeHighlightText(cachedSelection) === normalizeHighlightText(t);
|
||||||
cachedSelection = t;
|
cachedSelection = t;
|
||||||
|
if (position) {
|
||||||
|
cachedSelectionPosition = position;
|
||||||
|
} else if (!sameText) {
|
||||||
|
cachedSelectionPosition = undefined;
|
||||||
|
}
|
||||||
cachedAt = Date.now();
|
cachedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedReaderSelectionPosition(
|
||||||
|
text?: string,
|
||||||
|
maxAgeMs = 120000,
|
||||||
|
): PdfSelectionPosition | undefined {
|
||||||
|
if (!cachedSelectionPosition) return undefined;
|
||||||
|
if (Date.now() - cachedAt > maxAgeMs) return undefined;
|
||||||
|
if (!text?.trim()) return cachedSelectionPosition;
|
||||||
|
|
||||||
|
const wanted = normalizeHighlightText(text);
|
||||||
|
const cached = normalizeHighlightText(cachedSelection);
|
||||||
|
if (!wanted || !cached) return undefined;
|
||||||
|
if (wanted === cached || cached.includes(wanted) || wanted.includes(cached)) {
|
||||||
|
return cachedSelectionPosition;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCachedReaderSelection(maxAgeMs = 120000): string {
|
export function getCachedReaderSelection(maxAgeMs = 120000): string {
|
||||||
if (!cachedSelection) return "";
|
if (!cachedSelection) return "";
|
||||||
if (Date.now() - cachedAt > maxAgeMs) return "";
|
if (Date.now() - cachedAt > maxAgeMs) return "";
|
||||||
@@ -200,7 +279,10 @@ export function registerReaderSelectionHook() {
|
|||||||
tryString(event?.params?.annotation?.text) ||
|
tryString(event?.params?.annotation?.text) ||
|
||||||
tryString(event?.params?.text) ||
|
tryString(event?.params?.text) ||
|
||||||
textFromReaderInternals(event?.reader);
|
textFromReaderInternals(event?.reader);
|
||||||
if (text) cacheReaderSelection(text);
|
const position = normalizePdfSelectionPosition(
|
||||||
|
event?.params?.annotation?.position,
|
||||||
|
);
|
||||||
|
if (text) cacheReaderSelection(text, position);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ztoolkit.log("selection popup hook failed", e);
|
ztoolkit.log("selection popup hook failed", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ChatMessage } from "../llm/types";
|
import type { ChatMessage } from "../llm/types";
|
||||||
|
|
||||||
function sessionDir(): string {
|
function sessionDir(): string {
|
||||||
const profile = (Zotero as any).Profile?.dir || (Zotero as any).getProfileDirectory?.()?.path;
|
const profile = (Zotero as any).Profile?.dir;
|
||||||
if (!profile) {
|
if (!profile || typeof profile !== "string") {
|
||||||
throw new Error("Cannot resolve Zotero profile directory");
|
throw new Error("Cannot resolve Zotero profile directory");
|
||||||
}
|
}
|
||||||
return PathUtils.join(profile, "chatpapers", "sessions");
|
return PathUtils.join(profile, "chatpapers", "sessions");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
isAbortError,
|
isAbortError,
|
||||||
type AbortHandle,
|
type AbortHandle,
|
||||||
} from "../../utils/abort";
|
} from "../../utils/abort";
|
||||||
|
import { isItemPaneSectionBody } from "./itemPaneSection";
|
||||||
|
|
||||||
export class ChatView {
|
export class ChatView {
|
||||||
private body: HTMLElement;
|
private body: HTMLElement;
|
||||||
@@ -60,15 +61,19 @@ export class ChatView {
|
|||||||
const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined;
|
const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined;
|
||||||
const providerLabel = preset?.label || cfg.provider || "未配置";
|
const providerLabel = preset?.label || cfg.provider || "未配置";
|
||||||
|
|
||||||
|
const inItemPane = isItemPaneSectionBody(this.body);
|
||||||
const header = this.el("div", "chatpapers-header");
|
const header = this.el("div", "chatpapers-header");
|
||||||
const brand = this.el("div", "chatpapers-brand");
|
if (!inItemPane) {
|
||||||
brand.append(
|
const brand = this.el("div", "chatpapers-brand");
|
||||||
createLucideIcon(this.doc, MessageSquareText, {
|
brand.append(
|
||||||
size: 18,
|
createLucideIcon(this.doc, MessageSquareText, {
|
||||||
className: "chatpapers-brand-icon",
|
size: 18,
|
||||||
}),
|
className: "chatpapers-brand-icon",
|
||||||
this.el("div", "chatpapers-brand-text", "ChatPapers"),
|
}),
|
||||||
);
|
this.el("div", "chatpapers-brand-text", "ChatPapers"),
|
||||||
|
);
|
||||||
|
header.append(brand);
|
||||||
|
}
|
||||||
const titleRow = this.el("div", "chatpapers-title-row");
|
const titleRow = this.el("div", "chatpapers-title-row");
|
||||||
titleRow.append(
|
titleRow.append(
|
||||||
this.el(
|
this.el(
|
||||||
@@ -82,7 +87,7 @@ export class ChatView {
|
|||||||
createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }),
|
createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }),
|
||||||
this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`),
|
this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`),
|
||||||
);
|
);
|
||||||
header.append(brand, titleRow, meta);
|
header.append(titleRow, meta);
|
||||||
|
|
||||||
this.messagesEl = this.el("div", "chatpapers-messages");
|
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||||
this.emptyEl = this.el("div", "chatpapers-empty");
|
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getString } from "../../utils/locale";
|
|||||||
import { openMultiChatDialog } from "./multiChatDialog";
|
import { openMultiChatDialog } from "./multiChatDialog";
|
||||||
|
|
||||||
const MENU_CHAT_ID = "chatpapers-itemmenu-chat";
|
const MENU_CHAT_ID = "chatpapers-itemmenu-chat";
|
||||||
|
/** Legacy duplicate menu — unregister if still present from older builds. */
|
||||||
const MENU_COMPARE_ID = "chatpapers-itemmenu-compare";
|
const MENU_COMPARE_ID = "chatpapers-itemmenu-compare";
|
||||||
|
|
||||||
function getSelectedItems(): Zotero.Item[] {
|
function getSelectedItems(): Zotero.Item[] {
|
||||||
@@ -15,7 +16,18 @@ function getSelectedItems(): Zotero.Item[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeMenuById(id: string) {
|
||||||
|
try {
|
||||||
|
const doc = Zotero.getMainWindow()?.document;
|
||||||
|
doc?.getElementById(id)?.remove();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerItemMenus() {
|
export function registerItemMenus() {
|
||||||
|
removeMenuById(MENU_COMPARE_ID);
|
||||||
|
|
||||||
const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`;
|
const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`;
|
||||||
|
|
||||||
ztoolkit.Menu.register("item", {
|
ztoolkit.Menu.register("item", {
|
||||||
@@ -28,24 +40,4 @@ export function registerItemMenus() {
|
|||||||
openMultiChatDialog(items);
|
openMultiChatDialog(items);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ztoolkit.Menu.register("item", {
|
|
||||||
tag: "menuitem",
|
|
||||||
id: MENU_COMPARE_ID,
|
|
||||||
label: getString("itemmenu-compare"),
|
|
||||||
icon,
|
|
||||||
commandListener: () => {
|
|
||||||
const items = getSelectedItems();
|
|
||||||
if (items.length < 2) {
|
|
||||||
new ztoolkit.ProgressWindow("ChatPapers")
|
|
||||||
.createLine({
|
|
||||||
text: getString("multi-need-two"),
|
|
||||||
type: "fail",
|
|
||||||
})
|
|
||||||
.show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openMultiChatDialog(items);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/** Helpers for Zotero ItemPane collapsible sections. */
|
||||||
|
|
||||||
|
import { config } from "../../../package.json";
|
||||||
|
import { getLocaleID, getString } from "../../utils/locale";
|
||||||
|
import type { FluentMessageId } from "../../../typings/i10n";
|
||||||
|
import { getPref, setPref } from "../../utils/prefs";
|
||||||
|
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
|
||||||
|
|
||||||
|
export type ItemPaneKind = "chat" | "lecture";
|
||||||
|
|
||||||
|
const ADAPTIVE_PREF: Record<ItemPaneKind, keyof _ZoteroTypes.Prefs["PluginPrefsMap"]> =
|
||||||
|
{
|
||||||
|
chat: "chatPaneAdaptiveHeight",
|
||||||
|
lecture: "lecturePaneAdaptiveHeight",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
|
||||||
|
chat: 360,
|
||||||
|
lecture: 420,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Lucide-derived toolbar icons (see addon/content/icons/pane-*.svg). */
|
||||||
|
function paneIconURL(file: string): string {
|
||||||
|
return `chrome://${config.addonRef}/content/icons/${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_WINDOW_ICON = paneIconURL("pane-open-window.svg");
|
||||||
|
const ADAPTIVE_ICON = paneIconURL("pane-adaptive-height.svg");
|
||||||
|
const ADAPTIVE_ICON_OFF = paneIconURL("pane-adaptive-height-fixed.svg");
|
||||||
|
|
||||||
|
export function prepareItemPaneBody(body: HTMLElement): void {
|
||||||
|
body.classList.add("chatpapers-pane-body");
|
||||||
|
body.style.minHeight = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdaptiveHeight(kind: ItemPaneKind): boolean {
|
||||||
|
const val = getPref(ADAPTIVE_PREF[kind]);
|
||||||
|
return val !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionButtonTooltip(messageId: FluentMessageId): string {
|
||||||
|
const fromBranch = getString(messageId, { branch: "tooltiptext" });
|
||||||
|
const l10nId = getLocaleID(messageId);
|
||||||
|
if (fromBranch && fromBranch !== l10nId) return fromBranch;
|
||||||
|
return getString(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySectionButtonTooltip(
|
||||||
|
btn: HTMLElement,
|
||||||
|
messageId: FluentMessageId,
|
||||||
|
): void {
|
||||||
|
const l10nId = getLocaleID(messageId);
|
||||||
|
const tooltip = sectionButtonTooltip(messageId);
|
||||||
|
btn.setAttribute("data-l10n-id", l10nId);
|
||||||
|
btn.setAttribute("title", tooltip);
|
||||||
|
btn.setAttribute("tooltiptext", tooltip);
|
||||||
|
try {
|
||||||
|
btn.ownerDocument?.l10n?.setAttributes?.(btn, l10nId);
|
||||||
|
} catch {
|
||||||
|
// ignore if Fluent is not ready on this document yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleSectionButtonIcon(btn: HTMLElement, icon: string): void {
|
||||||
|
const url = `url('${icon}')`;
|
||||||
|
btn.style.setProperty("--custom-button-icon-light", url);
|
||||||
|
btn.style.setProperty("--custom-button-icon-dark", url);
|
||||||
|
btn.style.setProperty("list-style-image", url);
|
||||||
|
btn.style.setProperty("-moz-context-properties", "fill, fill-opacity");
|
||||||
|
btn.style.setProperty("fill", "currentColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSectionButton(
|
||||||
|
section: HTMLElement | null,
|
||||||
|
type: string,
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (!section) return null;
|
||||||
|
return (section.querySelector(`.${type}.section-custom-button`) ??
|
||||||
|
section.querySelector(`.${type}`)) as HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOpenInWindowButtonIcon(body: HTMLElement): boolean {
|
||||||
|
const section = resolveCollapsibleSection(body);
|
||||||
|
const btn = findSectionButton(section, "open-in-window");
|
||||||
|
if (!btn) return false;
|
||||||
|
styleSectionButtonIcon(btn, OPEN_WINDOW_ICON);
|
||||||
|
applySectionButtonTooltip(btn, "item-pane-open-window");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdaptiveButtonState(
|
||||||
|
body: HTMLElement,
|
||||||
|
adaptive: boolean,
|
||||||
|
): boolean {
|
||||||
|
const section = resolveCollapsibleSection(body);
|
||||||
|
const btn = findSectionButton(section, "adaptive-height");
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.setAttribute("aria-pressed", adaptive ? "true" : "false");
|
||||||
|
btn.classList.toggle("chatpapers-adaptive-active", adaptive);
|
||||||
|
styleSectionButtonIcon(btn, adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF);
|
||||||
|
applySectionButtonTooltip(
|
||||||
|
btn,
|
||||||
|
adaptive ? "item-pane-adaptive-height-on" : "item-pane-adaptive-height-off",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyItemPaneSectionButtonIcons(
|
||||||
|
body: HTMLElement,
|
||||||
|
adaptive = isAdaptiveHeight(
|
||||||
|
(body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind,
|
||||||
|
),
|
||||||
|
attempt = 0,
|
||||||
|
): void {
|
||||||
|
const openReady = applyOpenInWindowButtonIcon(body);
|
||||||
|
const adaptiveReady = updateAdaptiveButtonState(body, adaptive);
|
||||||
|
if ((openReady && adaptiveReady) || attempt >= 8) return;
|
||||||
|
|
||||||
|
const win = body.ownerDocument?.defaultView;
|
||||||
|
if (!win) return;
|
||||||
|
win.requestAnimationFrame(() => {
|
||||||
|
applyItemPaneSectionButtonIcons(body, adaptive, attempt + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAdaptiveHeight(
|
||||||
|
body: HTMLElement,
|
||||||
|
kind: ItemPaneKind,
|
||||||
|
adaptive = isAdaptiveHeight(kind),
|
||||||
|
): void {
|
||||||
|
body.classList.toggle("chatpapers-pane-adaptive", adaptive);
|
||||||
|
body.classList.toggle("chatpapers-pane-fixed", !adaptive);
|
||||||
|
body.dataset.chatpapersPaneKind = kind;
|
||||||
|
|
||||||
|
const section = resolveCollapsibleSection(body);
|
||||||
|
section?.classList.toggle("chatpapers-adaptive-height", adaptive);
|
||||||
|
section?.classList.toggle("chatpapers-fixed-height", !adaptive);
|
||||||
|
applyItemPaneSectionButtonIcons(body, adaptive);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleAdaptiveHeight(
|
||||||
|
body: HTMLElement,
|
||||||
|
kind: ItemPaneKind,
|
||||||
|
): boolean {
|
||||||
|
const next = !isAdaptiveHeight(kind);
|
||||||
|
setPref(ADAPTIVE_PREF[kind], next);
|
||||||
|
applyAdaptiveHeight(body, kind, next);
|
||||||
|
refreshSectionOpenHeight(body);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshSectionOpenHeight(body: HTMLElement): void {
|
||||||
|
const section = resolveCollapsibleSection(body);
|
||||||
|
if (!section?.hasAttribute("open")) return;
|
||||||
|
|
||||||
|
if (section.classList.contains("chatpapers-adaptive-height")) {
|
||||||
|
section.style.setProperty("--open-height", "auto");
|
||||||
|
body.style.removeProperty("max-height");
|
||||||
|
body.style.removeProperty("overflow");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = (body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind;
|
||||||
|
const maxH = FIXED_HEIGHT[kind] ?? 360;
|
||||||
|
body.style.maxHeight = `${maxH}px`;
|
||||||
|
body.style.overflowY = "auto";
|
||||||
|
section.style.setProperty("--open-height", `${maxH}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onItemPaneSectionToggle(options: {
|
||||||
|
body: HTMLElement;
|
||||||
|
event?: Event;
|
||||||
|
}): void {
|
||||||
|
const section = resolveCollapsibleSection(options.body, options.event);
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const open = section.hasAttribute("open");
|
||||||
|
const adaptive = section.classList.contains("chatpapers-adaptive-height");
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
options.body.style.removeProperty("height");
|
||||||
|
options.body.style.minHeight = "0";
|
||||||
|
|
||||||
|
if (adaptive) {
|
||||||
|
options.body.style.removeProperty("overflow");
|
||||||
|
options.body.style.removeProperty("max-height");
|
||||||
|
section.style.setProperty("--open-height", "auto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.body.style.overflowY = "auto";
|
||||||
|
const kind = (options.body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind;
|
||||||
|
const maxH = FIXED_HEIGHT[kind] ?? 360;
|
||||||
|
options.body.style.maxHeight = `${maxH}px`;
|
||||||
|
section.style.setProperty("--open-height", `${maxH}px`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.body.style.minHeight = "0";
|
||||||
|
options.body.style.height = "0";
|
||||||
|
options.body.style.overflow = "hidden";
|
||||||
|
options.body.style.removeProperty("max-height");
|
||||||
|
section.style.setProperty("--open-height", "0px");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildItemPaneSectionButtons(kind: ItemPaneKind) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "open-in-window",
|
||||||
|
icon: OPEN_WINDOW_ICON,
|
||||||
|
l10nID: getLocaleID("item-pane-open-window"),
|
||||||
|
onClick: ({ item }: { item?: Zotero.Item }) => {
|
||||||
|
if (!item) return;
|
||||||
|
if (kind === "chat") openChatPaneWindow(item);
|
||||||
|
else openLecturePaneWindow(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "adaptive-height",
|
||||||
|
icon: ADAPTIVE_ICON,
|
||||||
|
l10nID: getLocaleID("item-pane-adaptive-height"),
|
||||||
|
onClick: ({ body }: { body: HTMLElement }) => {
|
||||||
|
toggleAdaptiveHeight(body, kind);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isItemPaneSectionBody(body: HTMLElement): boolean {
|
||||||
|
return Boolean(body.closest("collapsible-section"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionSummaryUpdater = (summary: string) => void;
|
||||||
|
|
||||||
|
export function clearSectionSummary(
|
||||||
|
body: HTMLElement,
|
||||||
|
updater?: SectionSummaryUpdater,
|
||||||
|
): void {
|
||||||
|
updater?.("");
|
||||||
|
const section = body.closest("collapsible-section");
|
||||||
|
if (section) section.setAttribute("summary", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_HEAD_MESSAGE: Record<ItemPaneKind, FluentMessageId> = {
|
||||||
|
chat: "item-section-chat-head",
|
||||||
|
lecture: "item-section-lecture-head",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ensure native section head shows plugin name (not just the icon). */
|
||||||
|
export function applyItemPaneSectionHeadLabel(
|
||||||
|
body: HTMLElement,
|
||||||
|
kind: ItemPaneKind,
|
||||||
|
): void {
|
||||||
|
const section = body.closest("collapsible-section") as HTMLElement | null;
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const messageId = SECTION_HEAD_MESSAGE[kind];
|
||||||
|
const l10nId = getLocaleID(messageId);
|
||||||
|
section.dataset.l10nId = l10nId;
|
||||||
|
|
||||||
|
const label = getString(messageId, "label");
|
||||||
|
if (label && label !== l10nId) {
|
||||||
|
section.setAttribute("label", label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
body.ownerDocument?.l10n?.setAttributes?.(section, l10nId);
|
||||||
|
} catch {
|
||||||
|
// Fluent may not be ready yet; label stays empty until next render hook.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCollapsibleSection(
|
||||||
|
body: HTMLElement,
|
||||||
|
event?: Event,
|
||||||
|
): HTMLElement | null {
|
||||||
|
const fromEvent = (event?.currentTarget ?? event?.target) as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
return (
|
||||||
|
(fromEvent?.closest?.("collapsible-section") as HTMLElement | null) ??
|
||||||
|
(body.closest("collapsible-section") as HTMLElement | null)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { ChatView } from "./chatView";
|
||||||
|
import { LecturePaneView } from "../lecture/ui/lecturePane";
|
||||||
|
import { ensureChatPapersStyles } from "./readerPane";
|
||||||
|
|
||||||
|
type PaneKind = "chat" | "lecture";
|
||||||
|
|
||||||
|
interface OpenPaneWindow {
|
||||||
|
dialog: any;
|
||||||
|
view: ChatView | LecturePaneView;
|
||||||
|
onResize?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWindows = new Map<string, OpenPaneWindow>();
|
||||||
|
|
||||||
|
function windowKey(kind: PaneKind, itemId: number): string {
|
||||||
|
return `${kind}:${itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDialogStyles(doc: Document): void {
|
||||||
|
ensureChatPapersStyles(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredDialogSize(): { width: number; height: number } {
|
||||||
|
try {
|
||||||
|
const win = Zotero.getMainWindow?.() as Window | undefined;
|
||||||
|
const screen = win?.screen;
|
||||||
|
const aw = screen?.availWidth || win?.outerWidth || 1280;
|
||||||
|
const ah = screen?.availHeight || win?.outerHeight || 800;
|
||||||
|
return {
|
||||||
|
width: Math.max(720, Math.min(Math.round(aw * 0.82), aw - 48)),
|
||||||
|
height: Math.max(560, Math.min(Math.round(ah * 0.82), ah - 48)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { width: 920, height: 680 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDialogButtonBox(doc: Document): void {
|
||||||
|
for (const sel of [
|
||||||
|
"#dialog-button-box",
|
||||||
|
".dialog-button-box",
|
||||||
|
"dialog > vbox.dialog-button-box",
|
||||||
|
"[anonid='buttons']",
|
||||||
|
]) {
|
||||||
|
for (const node of Array.from(doc.querySelectorAll(sel))) {
|
||||||
|
(node as HTMLElement).style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitPaneRoot(win: Window, rootId: string): void {
|
||||||
|
const doc = win.document;
|
||||||
|
hideDialogButtonBox(doc);
|
||||||
|
const root = doc.getElementById(rootId) as HTMLElement | null;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
for (const el of [doc.documentElement, doc.body]) {
|
||||||
|
if (!el) continue;
|
||||||
|
(el as HTMLElement).style.height = "100%";
|
||||||
|
(el as HTMLElement).style.margin = "0";
|
||||||
|
(el as HTMLElement).style.padding = "0";
|
||||||
|
(el as HTMLElement).style.overflow = "hidden";
|
||||||
|
(el as HTMLElement).style.display = "flex";
|
||||||
|
(el as HTMLElement).style.flexDirection = "column";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of Array.from(doc.querySelectorAll("dialog"))) {
|
||||||
|
(node as HTMLElement).style.padding = "0";
|
||||||
|
(node as HTMLElement).style.margin = "0";
|
||||||
|
(node as HTMLElement).style.height = "100%";
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = Math.max(400, (win.innerHeight || 600) - 8);
|
||||||
|
root.style.width = "100%";
|
||||||
|
root.style.height = `${h}px`;
|
||||||
|
root.style.maxHeight = `${h}px`;
|
||||||
|
root.style.minHeight = "0";
|
||||||
|
root.style.flex = "1 1 auto";
|
||||||
|
root.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusExisting(key: string): boolean {
|
||||||
|
const existing = openWindows.get(key);
|
||||||
|
const win = existing?.dialog?.window as Window | undefined;
|
||||||
|
if (!win) return false;
|
||||||
|
try {
|
||||||
|
win.focus();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
openWindows.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWindow(key: string): void {
|
||||||
|
const existing = openWindows.get(key);
|
||||||
|
if (!existing) return;
|
||||||
|
try {
|
||||||
|
if (existing.dialog?.window && existing.onResize) {
|
||||||
|
existing.dialog.window.removeEventListener("resize", existing.onResize);
|
||||||
|
}
|
||||||
|
existing.view.destroy();
|
||||||
|
existing.dialog?.window?.close?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
openWindows.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPaneWindow(options: {
|
||||||
|
kind: PaneKind;
|
||||||
|
item: Zotero.Item;
|
||||||
|
rootId: string;
|
||||||
|
title: string;
|
||||||
|
mount: (doc: Document, root: HTMLElement) => Promise<ChatView | LecturePaneView>;
|
||||||
|
}): void {
|
||||||
|
const key = windowKey(options.kind, options.item.id);
|
||||||
|
if (focusExisting(key)) return;
|
||||||
|
|
||||||
|
closeWindow(key);
|
||||||
|
|
||||||
|
const size = getPreferredDialogSize();
|
||||||
|
let activeView: ChatView | LecturePaneView | null = null;
|
||||||
|
let onResize: (() => void) | null = null;
|
||||||
|
|
||||||
|
const dialogHelper = new ztoolkit.Dialog(1, 1)
|
||||||
|
.addCell(0, 0, {
|
||||||
|
tag: "div",
|
||||||
|
namespace: "html",
|
||||||
|
id: options.rootId,
|
||||||
|
styles: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minWidth: "560px",
|
||||||
|
minHeight: "0",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.setDialogData({
|
||||||
|
loadCallback: () => {
|
||||||
|
try {
|
||||||
|
const win = dialogHelper.window as Window;
|
||||||
|
const doc = win.document;
|
||||||
|
ensureDialogStyles(doc);
|
||||||
|
fitPaneRoot(win, options.rootId);
|
||||||
|
onResize = () => fitPaneRoot(win, options.rootId);
|
||||||
|
win.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
const root = doc.getElementById(options.rootId) as HTMLElement | null;
|
||||||
|
if (!root) return;
|
||||||
|
void options.mount(doc, root).then((view) => {
|
||||||
|
activeView = view;
|
||||||
|
openWindows.set(key, {
|
||||||
|
dialog: dialogHelper,
|
||||||
|
view,
|
||||||
|
onResize: onResize ?? undefined,
|
||||||
|
});
|
||||||
|
fitPaneRoot(win, options.rootId);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers] openPaneWindow load failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unloadCallback: () => {
|
||||||
|
try {
|
||||||
|
if (dialogHelper.window && onResize) {
|
||||||
|
dialogHelper.window.removeEventListener("resize", onResize);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
activeView?.destroy();
|
||||||
|
activeView = null;
|
||||||
|
onResize = null;
|
||||||
|
openWindows.delete(key);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
openWindows.set(key, {
|
||||||
|
dialog: dialogHelper,
|
||||||
|
view: null as unknown as ChatView,
|
||||||
|
onResize: onResize ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogHelper.open(options.title, {
|
||||||
|
centerscreen: true,
|
||||||
|
resizable: true,
|
||||||
|
noDialogMode: true,
|
||||||
|
fitContent: false,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openChatPaneWindow(item: Zotero.Item): void {
|
||||||
|
const title = String(item.getField("title") || "ChatPapers");
|
||||||
|
openPaneWindow({
|
||||||
|
kind: "chat",
|
||||||
|
item,
|
||||||
|
rootId: "chatpapers-window-chat-root",
|
||||||
|
title: `ChatPapers — ${title}`,
|
||||||
|
mount: async (doc, root) => {
|
||||||
|
const view = new ChatView(doc, root, item);
|
||||||
|
await view.mount();
|
||||||
|
return view;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openLecturePaneWindow(item: Zotero.Item): void {
|
||||||
|
const title = String(item.getField("title") || getString("item-section-lecture-head"));
|
||||||
|
openPaneWindow({
|
||||||
|
kind: "lecture",
|
||||||
|
item,
|
||||||
|
rootId: "chatpapers-window-lecture-root",
|
||||||
|
title: `${getString("item-section-lecture-head")} — ${title}`,
|
||||||
|
mount: async (doc, root) => {
|
||||||
|
const view = new LecturePaneView(doc, root, item);
|
||||||
|
await view.mount();
|
||||||
|
return view;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getPref, setPref } from "../../utils/prefs";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { getHostPlatform } from "../lecture/infrastructure/platform/os";
|
||||||
|
import {
|
||||||
|
TTS_PROVIDER_ORDER,
|
||||||
|
TTS_PROVIDER_PRESETS,
|
||||||
|
getDefaultTtsProviderId,
|
||||||
|
isTtsProviderAvailable,
|
||||||
|
} from "../lecture/infrastructure/tts/providers";
|
||||||
|
|
||||||
|
export function registerLecturePrefsScripts(win: Window) {
|
||||||
|
const doc = win.document;
|
||||||
|
const providerSelect = doc.getElementById(
|
||||||
|
"chatpapers-pref-tts-provider",
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const voiceInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-tts-voice",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const piperPathInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-piper-path",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const piperModelInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-piper-model",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const mineruPathInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-mineru-path",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const lectureDataDirInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-lecture-data-dir",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const hint = doc.getElementById("chatpapers-pref-tts-hint");
|
||||||
|
const piperBox = doc.getElementById("chatpapers-pref-piper-box");
|
||||||
|
|
||||||
|
if (providerSelect && providerSelect.options.length === 0) {
|
||||||
|
for (const id of TTS_PROVIDER_ORDER) {
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id];
|
||||||
|
if (!isTtsProviderAvailable(preset)) continue;
|
||||||
|
const opt = doc.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = preset.label;
|
||||||
|
providerSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const current = getPref("ttsProvider") || getDefaultTtsProviderId();
|
||||||
|
providerSelect.value = current;
|
||||||
|
if (!getPref("ttsProvider")) setPref("ttsProvider", current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHint = () => {
|
||||||
|
const id = providerSelect?.value || getPref("ttsProvider") || "";
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id as keyof typeof TTS_PROVIDER_PRESETS];
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = preset?.description || getString("prefs-tts-hint-empty");
|
||||||
|
}
|
||||||
|
if (piperBox) {
|
||||||
|
(piperBox as HTMLElement).hidden = id !== "piper";
|
||||||
|
}
|
||||||
|
if (voiceInput) {
|
||||||
|
const platform = getHostPlatform();
|
||||||
|
voiceInput.placeholder =
|
||||||
|
platform === "mac"
|
||||||
|
? "Ting-Ting(中文)/ Samantha(英文)"
|
||||||
|
: platform === "win"
|
||||||
|
? "Microsoft Huihui Desktop"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
providerSelect?.addEventListener("change", () => {
|
||||||
|
setPref("ttsProvider", providerSelect.value);
|
||||||
|
updateHint();
|
||||||
|
});
|
||||||
|
|
||||||
|
voiceInput?.addEventListener("change", () => {
|
||||||
|
setPref("ttsVoice", voiceInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
piperPathInput?.addEventListener("change", () => {
|
||||||
|
setPref("piperPath", piperPathInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
piperModelInput?.addEventListener("change", () => {
|
||||||
|
setPref("piperModelPath", piperModelInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
mineruPathInput?.addEventListener("change", () => {
|
||||||
|
setPref("mineruPath", mineruPathInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
lectureDataDirInput?.addEventListener("change", () => {
|
||||||
|
setPref("lectureDataDir", lectureDataDirInput.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHint();
|
||||||
|
}
|
||||||
@@ -198,15 +198,12 @@ export function openMultiChatDialog(items: Zotero.Item[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeDialog = dialogHelper;
|
activeDialog = dialogHelper;
|
||||||
dialogHelper.open(
|
dialogHelper.open("ChatPapers", {
|
||||||
`ChatPapers · ${Math.min(selected.length, 8)} papers`,
|
|
||||||
{
|
|
||||||
centerscreen: true,
|
centerscreen: true,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
noDialogMode: true,
|
noDialogMode: true,
|
||||||
fitContent: false,
|
fitContent: false,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
|
Download,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
Layers,
|
Layers,
|
||||||
ListTree,
|
ListTree,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
|
Plus,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
User,
|
User,
|
||||||
|
X,
|
||||||
type IconNode,
|
type IconNode,
|
||||||
} from "lucide";
|
} from "lucide";
|
||||||
import { chatStream, getRuntimeConfig } from "../llm/client";
|
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||||
@@ -21,6 +25,13 @@ import {
|
|||||||
type MultiChatMode,
|
type MultiChatMode,
|
||||||
} from "../pdf/multiContext";
|
} from "../pdf/multiContext";
|
||||||
import { createChildNote } from "../zotero/notes";
|
import { createChildNote } from "../zotero/notes";
|
||||||
|
import {
|
||||||
|
defaultCiteFormat,
|
||||||
|
formatCiteText,
|
||||||
|
listCiteFormatOptions,
|
||||||
|
saveCiteToFile,
|
||||||
|
type CiteFormatOption,
|
||||||
|
} from "../zotero/citeExport";
|
||||||
import { getString } from "../../utils/locale";
|
import { getString } from "../../utils/locale";
|
||||||
import { markdownToHtml } from "../../utils/markdown";
|
import { markdownToHtml } from "../../utils/markdown";
|
||||||
import { createLucideIcon } from "../../utils/icons";
|
import { createLucideIcon } from "../../utils/icons";
|
||||||
@@ -29,6 +40,11 @@ import {
|
|||||||
isAbortError,
|
isAbortError,
|
||||||
type AbortHandle,
|
type AbortHandle,
|
||||||
} from "../../utils/abort";
|
} from "../../utils/abort";
|
||||||
|
import { pickLibraryItems } from "./pickItems";
|
||||||
|
|
||||||
|
const MAX_PAPERS = 8;
|
||||||
|
|
||||||
|
let rememberedCiteFormat = "";
|
||||||
|
|
||||||
export class MultiChatView {
|
export class MultiChatView {
|
||||||
private body: HTMLElement;
|
private body: HTMLElement;
|
||||||
@@ -36,6 +52,7 @@ export class MultiChatView {
|
|||||||
private items: Zotero.Item[];
|
private items: Zotero.Item[];
|
||||||
private messages: ChatMessage[] = [];
|
private messages: ChatMessage[] = [];
|
||||||
private abort?: AbortHandle;
|
private abort?: AbortHandle;
|
||||||
|
private citeFormats: CiteFormatOption[] = [];
|
||||||
|
|
||||||
private messagesEl!: HTMLElement;
|
private messagesEl!: HTMLElement;
|
||||||
private inputEl!: HTMLTextAreaElement;
|
private inputEl!: HTMLTextAreaElement;
|
||||||
@@ -44,11 +61,22 @@ export class MultiChatView {
|
|||||||
private sendBtn!: HTMLButtonElement;
|
private sendBtn!: HTMLButtonElement;
|
||||||
private stopBtn!: HTMLButtonElement;
|
private stopBtn!: HTMLButtonElement;
|
||||||
private emptyEl!: HTMLElement;
|
private emptyEl!: HTMLElement;
|
||||||
|
private paperListEl!: HTMLElement;
|
||||||
|
private citeBarEl!: HTMLElement;
|
||||||
|
private citePickerEl!: HTMLElement;
|
||||||
|
private citeTriggerEl!: HTMLButtonElement;
|
||||||
|
private citeTriggerLabelEl!: HTMLElement;
|
||||||
|
private citeMenuEl!: HTMLElement;
|
||||||
|
private citeValue = "";
|
||||||
|
private titleEl!: HTMLElement;
|
||||||
|
private compareBtn!: HTMLButtonElement;
|
||||||
|
private summaryEachBtn!: HTMLButtonElement;
|
||||||
|
private commonBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) {
|
constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) {
|
||||||
this.doc = doc;
|
this.doc = doc;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
this.items = normalizeLibraryItems(items);
|
this.items = normalizeLibraryItems(items).slice(0, MAX_PAPERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async mount(): Promise<void> {
|
async mount(): Promise<void> {
|
||||||
@@ -66,22 +94,13 @@ export class MultiChatView {
|
|||||||
size: 18,
|
size: 18,
|
||||||
className: "chatpapers-brand-icon",
|
className: "chatpapers-brand-icon",
|
||||||
}),
|
}),
|
||||||
this.el(
|
this.el("div", "chatpapers-brand-text", getString("multi-title")),
|
||||||
"div",
|
|
||||||
"chatpapers-brand-text",
|
|
||||||
getString("multi-title"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const titles = this.items
|
this.titleEl = this.el("div", "chatpapers-title");
|
||||||
.map((it) => it.getField("title") || it.attachmentFilename || `#${it.id}`)
|
|
||||||
.slice(0, 6);
|
|
||||||
const more =
|
|
||||||
this.items.length > 6 ? ` 等 ${this.items.length} 篇` : `(${this.items.length} 篇)`;
|
|
||||||
|
|
||||||
header.append(
|
header.append(
|
||||||
brand,
|
brand,
|
||||||
this.el("div", "chatpapers-title", titles.join(" · ") + more),
|
this.titleEl,
|
||||||
this.el(
|
this.el(
|
||||||
"div",
|
"div",
|
||||||
"chatpapers-meta",
|
"chatpapers-meta",
|
||||||
@@ -89,20 +108,13 @@ export class MultiChatView {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const paperList = this.el("div", "chatpapers-paper-chips");
|
this.paperListEl = this.el("div", "chatpapers-paper-chips");
|
||||||
for (const [i, item] of this.items.entries()) {
|
this.citeBarEl = this.buildCiteBar();
|
||||||
const chip = this.el(
|
|
||||||
"span",
|
|
||||||
"chatpapers-chip",
|
|
||||||
`${i + 1}. ${item.getField("title") || item.attachmentFilename || item.id}`,
|
|
||||||
);
|
|
||||||
paperList.append(chip);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messagesEl = this.el("div", "chatpapers-messages");
|
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||||
this.emptyEl = this.el("div", "chatpapers-empty");
|
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||||
this.emptyEl.append(
|
this.emptyEl.append(
|
||||||
createLucideIcon(this.doc, GitCompare, {
|
createLucideIcon(this.doc, MessageSquareText, {
|
||||||
size: 28,
|
size: 28,
|
||||||
className: "chatpapers-empty-icon",
|
className: "chatpapers-empty-icon",
|
||||||
}),
|
}),
|
||||||
@@ -115,19 +127,24 @@ export class MultiChatView {
|
|||||||
this.statusEl.append(this.statusTextEl);
|
this.statusEl.append(this.statusTextEl);
|
||||||
|
|
||||||
const toolbar = this.el("div", "chatpapers-toolbar");
|
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||||
|
this.compareBtn = this.iconBtn(GitCompare, getString("multi-compare"), () =>
|
||||||
|
this.runModeGuarded("compare", getString("multi-compare")),
|
||||||
|
);
|
||||||
|
this.summaryEachBtn = this.iconBtn(
|
||||||
|
ListTree,
|
||||||
|
getString("multi-summary-each"),
|
||||||
|
() => this.runModeGuarded("summary-each", getString("multi-summary-each")),
|
||||||
|
);
|
||||||
|
this.commonBtn = this.iconBtn(Layers, getString("multi-common"), () =>
|
||||||
|
this.runModeGuarded("common-themes", getString("multi-common")),
|
||||||
|
);
|
||||||
toolbar.append(
|
toolbar.append(
|
||||||
this.iconBtn(NotebookPen, getString("multi-topic-review"), () =>
|
this.iconBtn(NotebookPen, getString("multi-topic-review"), () =>
|
||||||
void this.runTopicReview(),
|
void this.runTopicReview(),
|
||||||
),
|
),
|
||||||
this.iconBtn(GitCompare, getString("multi-compare"), () =>
|
this.compareBtn,
|
||||||
this.runMode("compare", getString("multi-compare")),
|
this.summaryEachBtn,
|
||||||
),
|
this.commonBtn,
|
||||||
this.iconBtn(ListTree, getString("multi-summary-each"), () =>
|
|
||||||
this.runMode("summary-each", getString("multi-summary-each")),
|
|
||||||
),
|
|
||||||
this.iconBtn(Layers, getString("multi-common"), () =>
|
|
||||||
this.runMode("common-themes", getString("multi-common")),
|
|
||||||
),
|
|
||||||
this.iconBtn(Sparkles, getString("chat-save-note"), () =>
|
this.iconBtn(Sparkles, getString("chat-save-note"), () =>
|
||||||
this.saveLastNote(),
|
this.saveLastNote(),
|
||||||
),
|
),
|
||||||
@@ -163,14 +180,17 @@ export class MultiChatView {
|
|||||||
|
|
||||||
this.body.append(
|
this.body.append(
|
||||||
header,
|
header,
|
||||||
paperList,
|
this.paperListEl,
|
||||||
|
this.citeBarEl,
|
||||||
this.messagesEl,
|
this.messagesEl,
|
||||||
this.statusEl,
|
this.statusEl,
|
||||||
toolbar,
|
toolbar,
|
||||||
composer,
|
composer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.refreshPaperChrome();
|
||||||
this.renderMessages();
|
this.renderMessages();
|
||||||
|
void this.loadCiteFormats();
|
||||||
if (!cfg.provider || !cfg.baseUrl) {
|
if (!cfg.provider || !cfg.baseUrl) {
|
||||||
this.setStatus(getString("chat-need-config"), "warn");
|
this.setStatus(getString("chat-need-config"), "warn");
|
||||||
} else {
|
} else {
|
||||||
@@ -183,9 +203,186 @@ export class MultiChatView {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
try {
|
||||||
|
const onDocClick = (this.citePickerEl as any)?._onDocClick;
|
||||||
|
if (onDocClick) this.doc.removeEventListener("click", onDocClick, true);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
this.body.replaceChildren();
|
this.body.replaceChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCiteBar(): HTMLElement {
|
||||||
|
const bar = this.el("div", "chatpapers-cite-bar");
|
||||||
|
const label = this.el(
|
||||||
|
"span",
|
||||||
|
"chatpapers-cite-label",
|
||||||
|
getString("cite-style"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom picker — native <select> breaks in Zotero dialogs (SelectParent / menupopup).
|
||||||
|
this.citePickerEl = this.el("div", "chatpapers-cite-picker");
|
||||||
|
this.citeTriggerEl = this.doc.createElement("button");
|
||||||
|
this.citeTriggerEl.type = "button";
|
||||||
|
this.citeTriggerEl.className = "chatpapers-cite-trigger";
|
||||||
|
this.citeTriggerEl.title = getString("cite-style");
|
||||||
|
this.citeTriggerLabelEl = this.el(
|
||||||
|
"span",
|
||||||
|
"chatpapers-cite-trigger-label",
|
||||||
|
getString("cite-loading"),
|
||||||
|
);
|
||||||
|
this.citeTriggerEl.append(
|
||||||
|
this.citeTriggerLabelEl,
|
||||||
|
createLucideIcon(this.doc, ChevronDown, {
|
||||||
|
size: 14,
|
||||||
|
className: "chatpapers-cite-chevron",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.citeMenuEl = this.el("div", "chatpapers-cite-menu");
|
||||||
|
this.citeMenuEl.hidden = true;
|
||||||
|
this.citePickerEl.append(this.citeTriggerEl, this.citeMenuEl);
|
||||||
|
|
||||||
|
this.citeTriggerEl.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.toggleCiteMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDocClick = (ev: Event) => {
|
||||||
|
const t = ev.target as Node | null;
|
||||||
|
if (!t || this.citePickerEl.contains(t)) return;
|
||||||
|
this.closeCiteMenu();
|
||||||
|
};
|
||||||
|
this.doc.addEventListener("click", onDocClick, true);
|
||||||
|
// Store for cleanup via destroy
|
||||||
|
(this.citePickerEl as any)._onDocClick = onDocClick;
|
||||||
|
|
||||||
|
const copyBtn = this.iconBtn(Copy, getString("cite-copy"), () =>
|
||||||
|
void this.copyCite(),
|
||||||
|
);
|
||||||
|
copyBtn.classList.add("chatpapers-cite-btn");
|
||||||
|
const exportBtn = this.iconBtn(Download, getString("cite-export"), () =>
|
||||||
|
void this.exportCite(),
|
||||||
|
);
|
||||||
|
exportBtn.classList.add("chatpapers-cite-btn");
|
||||||
|
|
||||||
|
bar.append(label, this.citePickerEl, copyBtn, exportBtn);
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleCiteMenu() {
|
||||||
|
if (this.citeMenuEl.hidden) this.openCiteMenu();
|
||||||
|
else this.closeCiteMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCiteMenu() {
|
||||||
|
this.citeMenuEl.hidden = false;
|
||||||
|
this.citePickerEl.classList.add("is-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeCiteMenu() {
|
||||||
|
this.citeMenuEl.hidden = true;
|
||||||
|
this.citePickerEl.classList.remove("is-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCiteValue(value: string, label?: string) {
|
||||||
|
this.citeValue = value;
|
||||||
|
rememberedCiteFormat = value;
|
||||||
|
const found = this.citeFormats.find((f) => f.value === value);
|
||||||
|
this.citeTriggerLabelEl.textContent =
|
||||||
|
label || found?.label || getString("cite-empty");
|
||||||
|
this.citeTriggerEl.disabled = !value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCiteFormats() {
|
||||||
|
try {
|
||||||
|
this.citeFormats = await listCiteFormatOptions();
|
||||||
|
this.citeMenuEl.replaceChildren();
|
||||||
|
if (!this.citeFormats.length) {
|
||||||
|
this.setCiteValue("", getString("cite-empty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const fmt of this.citeFormats) {
|
||||||
|
const opt = this.doc.createElement("button");
|
||||||
|
opt.type = "button";
|
||||||
|
opt.className = "chatpapers-cite-option";
|
||||||
|
opt.dataset.value = fmt.value;
|
||||||
|
opt.textContent = fmt.label;
|
||||||
|
opt.title = fmt.label;
|
||||||
|
opt.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.setCiteValue(fmt.value, fmt.label);
|
||||||
|
this.closeCiteMenu();
|
||||||
|
this.highlightCiteOption(fmt.value);
|
||||||
|
});
|
||||||
|
this.citeMenuEl.append(opt);
|
||||||
|
}
|
||||||
|
const preferred =
|
||||||
|
(rememberedCiteFormat &&
|
||||||
|
this.citeFormats.some((f) => f.value === rememberedCiteFormat) &&
|
||||||
|
rememberedCiteFormat) ||
|
||||||
|
defaultCiteFormat(this.citeFormats);
|
||||||
|
this.setCiteValue(preferred);
|
||||||
|
this.highlightCiteOption(preferred);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("loadCiteFormats failed", e);
|
||||||
|
this.citeMenuEl.replaceChildren();
|
||||||
|
this.setCiteValue("", getString("cite-empty"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private highlightCiteOption(value: string) {
|
||||||
|
for (const node of Array.from(this.citeMenuEl.children)) {
|
||||||
|
const btn = node as HTMLElement;
|
||||||
|
btn.classList.toggle("is-active", btn.dataset.value === value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentCiteFormat(): string {
|
||||||
|
return this.citeValue || rememberedCiteFormat || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyCite() {
|
||||||
|
const format = this.currentCiteFormat();
|
||||||
|
if (!format) {
|
||||||
|
this.setStatus(getString("cite-empty"), "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { text } = await formatCiteText(this.items, format);
|
||||||
|
rememberedCiteFormat = format;
|
||||||
|
try {
|
||||||
|
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
|
||||||
|
} catch {
|
||||||
|
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
|
||||||
|
}
|
||||||
|
this.setStatus(getString("cite-copied"), "info");
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportCite() {
|
||||||
|
const format = this.currentCiteFormat();
|
||||||
|
if (!format) {
|
||||||
|
this.setStatus(getString("cite-empty"), "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { text, ext } = await formatCiteText(this.items, format);
|
||||||
|
rememberedCiteFormat = format;
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
const filename = `chatpapers-refs-${stamp}.${ext}`;
|
||||||
|
const win = this.doc.defaultView || Zotero.getMainWindow();
|
||||||
|
const saved = await saveCiteToFile(win as Window, filename, text);
|
||||||
|
if (saved) this.setStatus(getString("cite-exported"), "info");
|
||||||
|
else this.setStatus(getString("cite-export-cancel"), "warn");
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private el(tag: string, className?: string, text?: string): HTMLElement {
|
private el(tag: string, className?: string, text?: string): HTMLElement {
|
||||||
const node = this.doc.createElement(tag);
|
const node = this.doc.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
@@ -218,6 +415,124 @@ export class MultiChatView {
|
|||||||
this.statusEl.dataset.kind = kind;
|
this.statusEl.dataset.kind = kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private itemLabel(item: Zotero.Item): string {
|
||||||
|
return (
|
||||||
|
item.getField("title") ||
|
||||||
|
item.attachmentFilename ||
|
||||||
|
`#${item.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshPaperChrome() {
|
||||||
|
const titles = this.items.map((it) => this.itemLabel(it)).slice(0, 4);
|
||||||
|
const more =
|
||||||
|
this.items.length > 4
|
||||||
|
? ` 等 ${this.items.length} 篇`
|
||||||
|
: `(${this.items.length} 篇)`;
|
||||||
|
this.titleEl.textContent = titles.join(" · ") + more;
|
||||||
|
|
||||||
|
this.paperListEl.replaceChildren();
|
||||||
|
for (const [i, item] of this.items.entries()) {
|
||||||
|
const chip = this.el("span", "chatpapers-chip");
|
||||||
|
const label = this.el(
|
||||||
|
"span",
|
||||||
|
"chatpapers-chip-label",
|
||||||
|
`${i + 1}. ${this.itemLabel(item)}`,
|
||||||
|
);
|
||||||
|
label.title = this.itemLabel(item);
|
||||||
|
chip.append(label);
|
||||||
|
|
||||||
|
if (this.items.length > 1) {
|
||||||
|
const remove = this.doc.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "chatpapers-chip-remove";
|
||||||
|
remove.title = getString("multi-remove-paper");
|
||||||
|
remove.append(createLucideIcon(this.doc, X, { size: 11 }));
|
||||||
|
remove.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.removeItem(item.id);
|
||||||
|
});
|
||||||
|
chip.append(remove);
|
||||||
|
}
|
||||||
|
this.paperListEl.append(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = this.doc.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "chatpapers-chip chatpapers-chip-add";
|
||||||
|
addBtn.title = getString("multi-add-paper");
|
||||||
|
addBtn.append(
|
||||||
|
createLucideIcon(this.doc, Plus, { size: 12 }),
|
||||||
|
this.el("span", undefined, getString("multi-add-paper")),
|
||||||
|
);
|
||||||
|
addBtn.disabled = this.items.length >= MAX_PAPERS;
|
||||||
|
addBtn.addEventListener("click", () => void this.addPapersFromLibrary());
|
||||||
|
this.paperListEl.append(addBtn);
|
||||||
|
|
||||||
|
const multiOk = this.items.length >= 2;
|
||||||
|
for (const btn of [this.compareBtn, this.summaryEachBtn, this.commonBtn]) {
|
||||||
|
btn.disabled = !multiOk;
|
||||||
|
btn.title = multiOk
|
||||||
|
? btn.querySelector(".chatpapers-btn-label")?.textContent || ""
|
||||||
|
: getString("multi-need-two");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeItem(id: number) {
|
||||||
|
if (this.items.length <= 1) return;
|
||||||
|
this.items = this.items.filter((it) => it.id !== id);
|
||||||
|
this.refreshPaperChrome();
|
||||||
|
this.setStatus(
|
||||||
|
getString("multi-paper-removed", { args: { count: this.items.length } }),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addPapersFromLibrary() {
|
||||||
|
if (this.items.length >= MAX_PAPERS) {
|
||||||
|
this.setStatus(
|
||||||
|
getString("multi-too-many", { args: { count: this.items.length } }),
|
||||||
|
"warn",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const picked = await pickLibraryItems({ multiSelect: true });
|
||||||
|
if (!picked.length) return;
|
||||||
|
|
||||||
|
const existing = new Set(this.items.map((i) => i.id));
|
||||||
|
const incoming = normalizeLibraryItems(picked).filter(
|
||||||
|
(it) => !existing.has(it.id),
|
||||||
|
);
|
||||||
|
if (!incoming.length) {
|
||||||
|
this.setStatus(getString("multi-add-none"), "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = MAX_PAPERS - this.items.length;
|
||||||
|
const toAdd = incoming.slice(0, room);
|
||||||
|
this.items = [...this.items, ...toAdd];
|
||||||
|
this.refreshPaperChrome();
|
||||||
|
|
||||||
|
if (incoming.length > room) {
|
||||||
|
this.setStatus(
|
||||||
|
getString("multi-too-many", { args: { count: this.items.length } }),
|
||||||
|
"warn",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.setStatus(
|
||||||
|
getString("multi-paper-added", {
|
||||||
|
args: { added: toAdd.length, count: this.items.length },
|
||||||
|
}),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private renderMessages() {
|
private renderMessages() {
|
||||||
this.messagesEl.replaceChildren();
|
this.messagesEl.replaceChildren();
|
||||||
const visible = this.messages.filter((m) => m.role !== "system");
|
const visible = this.messages.filter((m) => m.role !== "system");
|
||||||
@@ -321,11 +636,14 @@ export class MultiChatView {
|
|||||||
await this.run("chat", text);
|
await this.run("chat", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runMode(mode: MultiChatMode, label: string) {
|
private runModeGuarded(mode: MultiChatMode, label: string) {
|
||||||
await this.run(mode, label);
|
if (this.items.length < 2) {
|
||||||
|
this.setStatus(getString("multi-need-two"), "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.run(mode, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Topic-oriented lit review for writing; topic comes from the input box. */
|
|
||||||
private async runTopicReview() {
|
private async runTopicReview() {
|
||||||
const topic = this.inputEl.value.trim();
|
const topic = this.inputEl.value.trim();
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
@@ -403,13 +721,14 @@ export class MultiChatView {
|
|||||||
}
|
}
|
||||||
const parent = this.items.find((i) => i.isRegularItem()) || this.items[0];
|
const parent = this.items.find((i) => i.isRegularItem()) || this.items[0];
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
const noteParent = parent.isAttachment() && parent.parentItemID
|
const noteParent =
|
||||||
? Zotero.Items.get(parent.parentItemID) || parent
|
parent.isAttachment() && parent.parentItemID
|
||||||
: parent;
|
? Zotero.Items.get(parent.parentItemID) || parent
|
||||||
|
: parent;
|
||||||
try {
|
try {
|
||||||
await createChildNote({
|
await createChildNote({
|
||||||
parentItem: noteParent,
|
parentItem: noteParent,
|
||||||
title: `ChatPapers 多篇对照 · ${new Date().toLocaleString()}`,
|
title: `ChatPapers 对话 · ${new Date().toLocaleString()}`,
|
||||||
bodyMarkdown: last.content,
|
bodyMarkdown: last.content,
|
||||||
});
|
});
|
||||||
this.setStatus(getString("chat-note-saved"), "info");
|
this.setStatus(getString("chat-note-saved"), "info");
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Open Zotero's built-in Select Items dialog and return chosen library items.
|
||||||
|
*/
|
||||||
|
export async function pickLibraryItems(options?: {
|
||||||
|
multiSelect?: boolean;
|
||||||
|
}): Promise<Zotero.Item[]> {
|
||||||
|
const multiSelect = options?.multiSelect !== false;
|
||||||
|
const mainWin = Zotero.getMainWindow?.() as
|
||||||
|
| (Window & { openDialog?: (...args: any[]) => Window | null })
|
||||||
|
| undefined;
|
||||||
|
if (!mainWin?.openDialog) {
|
||||||
|
throw new Error("无法打开文献选择窗口");
|
||||||
|
}
|
||||||
|
|
||||||
|
const io: {
|
||||||
|
multiSelect: boolean;
|
||||||
|
onlyRegularItems: boolean;
|
||||||
|
dataOut: number[] | null;
|
||||||
|
itemTreeID: string;
|
||||||
|
} = {
|
||||||
|
multiSelect,
|
||||||
|
onlyRegularItems: true,
|
||||||
|
dataOut: null,
|
||||||
|
itemTreeID: "chatpapers-select-items",
|
||||||
|
};
|
||||||
|
|
||||||
|
const urls = [
|
||||||
|
"chrome://zotero/content/selectItemsDialog.xhtml",
|
||||||
|
"chrome://zotero/content/selectItemsDialog.xul",
|
||||||
|
];
|
||||||
|
|
||||||
|
let opened = false;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
mainWin.openDialog(
|
||||||
|
url,
|
||||||
|
"chatpapers-select-items-dialog",
|
||||||
|
"chrome,modal,centerscreen,resizable=yes",
|
||||||
|
io,
|
||||||
|
);
|
||||||
|
opened = true;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("pickLibraryItems openDialog failed", url, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
throw new Error("当前 Zotero 版本无法打开文献选择对话框");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.isArray(io.dataOut) ? io.dataOut : [];
|
||||||
|
if (!ids.length) return [];
|
||||||
|
|
||||||
|
return (Zotero.Items.get(ids).filter(Boolean) as Zotero.Item[]) || [];
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "../llm/providers";
|
} from "../llm/providers";
|
||||||
import { getString } from "../../utils/locale";
|
import { getString } from "../../utils/locale";
|
||||||
|
|
||||||
|
import { registerLecturePrefsScripts } from "./lecturePrefs";
|
||||||
|
|
||||||
export function registerPrefsScripts(win: Window) {
|
export function registerPrefsScripts(win: Window) {
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
const providerSelect = doc.getElementById(
|
const providerSelect = doc.getElementById(
|
||||||
@@ -99,4 +101,5 @@ export function registerPrefsScripts(win: Window) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateHint();
|
updateHint();
|
||||||
|
registerLecturePrefsScripts(win);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { config } from "../../../package.json";
|
import { config } from "../../../package.json";
|
||||||
import { getLocaleID, getString } from "../../utils/locale";
|
import { getLocaleID, getString } from "../../utils/locale";
|
||||||
import { ChatView } from "./chatView";
|
import { ChatView } from "./chatView";
|
||||||
|
import {
|
||||||
|
applyAdaptiveHeight,
|
||||||
|
applyItemPaneSectionButtonIcons,
|
||||||
|
applyItemPaneSectionHeadLabel,
|
||||||
|
buildItemPaneSectionButtons,
|
||||||
|
clearSectionSummary,
|
||||||
|
onItemPaneSectionToggle,
|
||||||
|
prepareItemPaneBody,
|
||||||
|
refreshSectionOpenHeight,
|
||||||
|
} from "./itemPaneSection";
|
||||||
|
|
||||||
const views = new WeakMap<HTMLElement, ChatView>();
|
const views = new WeakMap<HTMLElement, ChatView>();
|
||||||
|
|
||||||
@@ -20,9 +30,13 @@ export function registerChatPane() {
|
|||||||
l10nID: getLocaleID("item-section-chat-sidenav"),
|
l10nID: getLocaleID("item-section-chat-sidenav"),
|
||||||
icon: iconURL("chat.svg"),
|
icon: iconURL("chat.svg"),
|
||||||
},
|
},
|
||||||
|
sectionButtons: buildItemPaneSectionButtons("chat"),
|
||||||
onInit: ({ body }) => {
|
onInit: ({ body }) => {
|
||||||
const doc = body.ownerDocument;
|
const doc = body.ownerDocument;
|
||||||
if (doc) ensureStyles(doc);
|
if (doc) ensureStyles(doc);
|
||||||
|
prepareItemPaneBody(body);
|
||||||
|
applyAdaptiveHeight(body, "chat");
|
||||||
|
applyItemPaneSectionHeadLabel(body, "chat");
|
||||||
},
|
},
|
||||||
onDestroy: ({ body }) => {
|
onDestroy: ({ body }) => {
|
||||||
views.get(body)?.destroy();
|
views.get(body)?.destroy();
|
||||||
@@ -34,16 +48,19 @@ export function registerChatPane() {
|
|||||||
void tabType;
|
void tabType;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onRender: ({ body }) => {
|
onRender: ({ body, setSectionSummary }) => {
|
||||||
body.replaceChildren();
|
body.replaceChildren();
|
||||||
const doc = body.ownerDocument;
|
const doc = body.ownerDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
applyItemPaneSectionHeadLabel(body, "chat");
|
||||||
|
clearSectionSummary(body, setSectionSummary);
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
const loading = doc.createElement("div");
|
const loading = doc.createElement("div");
|
||||||
loading.className = "chatpapers-loading";
|
loading.className = "chatpapers-loading";
|
||||||
loading.textContent = getString("chat-loading");
|
loading.textContent = getString("chat-loading");
|
||||||
body.append(loading);
|
body.append(loading);
|
||||||
},
|
},
|
||||||
onAsyncRender: async ({ body, item }) => {
|
onAsyncRender: async ({ body, item, setSectionSummary }) => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const doc = body.ownerDocument;
|
const doc = body.ownerDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
@@ -51,6 +68,20 @@ export function registerChatPane() {
|
|||||||
const view = new ChatView(doc, body, item);
|
const view = new ChatView(doc, body, item);
|
||||||
views.set(body, view);
|
views.set(body, view);
|
||||||
await view.mount();
|
await view.mount();
|
||||||
|
applyItemPaneSectionHeadLabel(body, "chat");
|
||||||
|
clearSectionSummary(body, setSectionSummary);
|
||||||
|
applyAdaptiveHeight(body, "chat");
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
|
const section = body.closest("collapsible-section");
|
||||||
|
if (section?.hasAttribute("open")) {
|
||||||
|
refreshSectionOpenHeight(body);
|
||||||
|
} else if (section) {
|
||||||
|
onItemPaneSectionToggle({ body });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggle: ({ body, event }) => {
|
||||||
|
applyItemPaneSectionButtonIcons(body);
|
||||||
|
onItemPaneSectionToggle({ body, event });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,13 +95,30 @@ export function registerPrefs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureStyles(doc: Document) {
|
export function ensureChatPapersStyles(doc: Document): void {
|
||||||
const id = "chatpapers-styles";
|
const id = "chatpapers-styles";
|
||||||
if (doc.getElementById(id)) return;
|
const base = `chrome://${config.addonRef}/content/chatpapers.css`;
|
||||||
|
const href =
|
||||||
|
typeof __env__ !== "undefined" && __env__ === "development"
|
||||||
|
? `${base}?v=${Date.now()}`
|
||||||
|
: base;
|
||||||
|
|
||||||
|
const existing = doc.getElementById(id) as HTMLLinkElement | null;
|
||||||
|
if (existing) {
|
||||||
|
if (typeof __env__ !== "undefined" && __env__ === "development") {
|
||||||
|
existing.href = href;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.id = id;
|
link.id = id;
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.type = "text/css";
|
link.type = "text/css";
|
||||||
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
|
link.href = href;
|
||||||
doc.documentElement?.appendChild(link);
|
doc.documentElement?.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureStyles(doc: Document) {
|
||||||
|
ensureChatPapersStyles(doc);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { normalizeLibraryItems } from "../pdf/multiContext";
|
||||||
|
|
||||||
|
export interface CiteFormatOption {
|
||||||
|
/** QuickCopy / export key, e.g. export:<id> or bibliography:<styleID> */
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
kind: "export" | "bibliography";
|
||||||
|
/** Lower sorts first within priority band */
|
||||||
|
sortKey: string;
|
||||||
|
/** 0 = BibTeX/LaTeX, 1 = GB/T 7714, 2 = user quick-copy, 3 = other */
|
||||||
|
band: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIBTEX_ID = "9cb70025-a888-4a29-a210-93ec52da40d4";
|
||||||
|
const BIBLATEX_ID = "b6e39b57-8942-4d11-8259-342c46ce395f";
|
||||||
|
|
||||||
|
/** Prefer Better BibTeX / built-in BibTeX when available. */
|
||||||
|
async function resolveTranslatorId(
|
||||||
|
preferredLabels: string[],
|
||||||
|
fallbackId?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const translators = await (Zotero.Translators as any).getAllForType(
|
||||||
|
"export",
|
||||||
|
);
|
||||||
|
const list = (translators || []) as Array<{
|
||||||
|
translatorID: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
for (const want of preferredLabels) {
|
||||||
|
const exact = list.find(
|
||||||
|
(t) => t.label.toLowerCase() === want.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (exact) return exact.translatorID;
|
||||||
|
}
|
||||||
|
for (const want of preferredLabels) {
|
||||||
|
const fuzzy = list.find((t) =>
|
||||||
|
t.label.toLowerCase().includes(want.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (fuzzy) return fuzzy.translatorID;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("resolveTranslatorId failed", e);
|
||||||
|
}
|
||||||
|
return fallbackId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGbtStyle(id: string, title: string): boolean {
|
||||||
|
const s = `${id} ${title}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
s.includes("7714") ||
|
||||||
|
s.includes("gb-t") ||
|
||||||
|
s.includes("gbt") ||
|
||||||
|
s.includes("china-national-standard")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regular parent items suitable for citation export. */
|
||||||
|
export function toCiteItems(items: Zotero.Item[]): Zotero.Item[] {
|
||||||
|
const map = new Map<number, Zotero.Item>();
|
||||||
|
for (const raw of normalizeLibraryItems(items)) {
|
||||||
|
let item = raw;
|
||||||
|
if (item.isAttachment() && item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID);
|
||||||
|
if (parent) item = parent;
|
||||||
|
}
|
||||||
|
if (item.isRegularItem()) map.set(item.id, item);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build dropdown options: BibTeX/BibLaTeX first, then GB/T 7714,
|
||||||
|
* then the user's Quick Copy style, then other installed CSL styles.
|
||||||
|
*/
|
||||||
|
export async function listCiteFormatOptions(): Promise<CiteFormatOption[]> {
|
||||||
|
try {
|
||||||
|
await (Zotero.Schema as any).schemaUpdatePromise;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await (Zotero.Schema as any).schemeUpdatePromise;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: CiteFormatOption[] = [];
|
||||||
|
|
||||||
|
const bibtexId = await resolveTranslatorId(
|
||||||
|
["Better BibTeX", "BibTeX"],
|
||||||
|
BIBTEX_ID,
|
||||||
|
);
|
||||||
|
if (bibtexId) {
|
||||||
|
options.push({
|
||||||
|
value: `export:${bibtexId}`,
|
||||||
|
label: "BibTeX (LaTeX)",
|
||||||
|
kind: "export",
|
||||||
|
sortKey: "0-bibtex",
|
||||||
|
band: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const biblatexId = await resolveTranslatorId(
|
||||||
|
["Better BibLaTeX", "BibLaTeX"],
|
||||||
|
BIBLATEX_ID,
|
||||||
|
);
|
||||||
|
if (biblatexId && biblatexId !== bibtexId) {
|
||||||
|
options.push({
|
||||||
|
value: `export:${biblatexId}`,
|
||||||
|
label: "BibLaTeX (LaTeX)",
|
||||||
|
kind: "export",
|
||||||
|
sortKey: "1-biblatex",
|
||||||
|
band: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let quickCopyStyleId = "";
|
||||||
|
try {
|
||||||
|
const setting = String(Zotero.Prefs.get("export.quickCopy.setting") || "");
|
||||||
|
if (setting.startsWith("bibliography=")) {
|
||||||
|
quickCopyStyleId = setting.slice("bibliography=".length);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const styles = (Zotero.Styles as any).getVisible() as Array<{
|
||||||
|
styleID: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
for (const style of styles || []) {
|
||||||
|
const id = style.styleID;
|
||||||
|
const title = style.title || id;
|
||||||
|
if (!id) continue;
|
||||||
|
let band = 3;
|
||||||
|
if (isGbtStyle(id, title)) band = 1;
|
||||||
|
else if (quickCopyStyleId && id === quickCopyStyleId) band = 2;
|
||||||
|
options.push({
|
||||||
|
value: `bibliography:${id}`,
|
||||||
|
label: band === 1 ? `GB/T · ${title}` : title,
|
||||||
|
kind: "bibliography",
|
||||||
|
sortKey: title.toLowerCase(),
|
||||||
|
band,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("listCiteFormatOptions styles failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.sort((a, b) => {
|
||||||
|
if (a.band !== b.band) return a.band - b.band;
|
||||||
|
return a.sortKey.localeCompare(b.sortKey, "zh");
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportViaTranslator(
|
||||||
|
items: Zotero.Item[],
|
||||||
|
translatorID: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const TranslateExport = (Zotero as any).Translate?.Export;
|
||||||
|
if (!TranslateExport) {
|
||||||
|
throw new Error("当前环境不支持导出翻译器");
|
||||||
|
}
|
||||||
|
const translation = new TranslateExport();
|
||||||
|
translation.setItems(items);
|
||||||
|
translation.setTranslator(translatorID);
|
||||||
|
translation.setDisplayOptions?.({ exportNotes: false });
|
||||||
|
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
translation.setHandler("done", (_obj: any, success: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (!success) {
|
||||||
|
reject(new Error("导出失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
(typeof translation.string === "string" && translation.string) ||
|
||||||
|
(typeof _obj?.string === "string" && _obj.string) ||
|
||||||
|
"";
|
||||||
|
resolve(text);
|
||||||
|
});
|
||||||
|
translation.setHandler("error", (_obj: any, err: unknown) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const maybe = translation.translate();
|
||||||
|
if (maybe && typeof maybe.then === "function") {
|
||||||
|
maybe.then((result: any) => {
|
||||||
|
if (settled) return;
|
||||||
|
if (typeof result === "string" && result) {
|
||||||
|
settled = true;
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}).catch((e: unknown) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickCopyContent(
|
||||||
|
items: Zotero.Item[],
|
||||||
|
format: string,
|
||||||
|
): Promise<{ text?: string; html?: string }> {
|
||||||
|
const qc = Zotero.QuickCopy as any;
|
||||||
|
let result = qc.getContentFromItems(items, format);
|
||||||
|
if (result && typeof result.then === "function") {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
// Some builds expose getContentFromItemsAsync
|
||||||
|
if (!result?.text && !result?.html && typeof qc.getContentFromItemsAsync === "function") {
|
||||||
|
result = await qc.getContentFromItemsAsync(items, format);
|
||||||
|
}
|
||||||
|
return result || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format current papers as plain text for the selected cite format. */
|
||||||
|
export async function formatCiteText(
|
||||||
|
items: Zotero.Item[],
|
||||||
|
formatValue: string,
|
||||||
|
): Promise<{ text: string; ext: string; mimeHint: string }> {
|
||||||
|
const citeItems = toCiteItems(items);
|
||||||
|
if (!citeItems.length) {
|
||||||
|
throw new Error("没有可导出的文献条目");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatValue.startsWith("export:")) {
|
||||||
|
const translatorID = formatValue.slice("export:".length);
|
||||||
|
let text = "";
|
||||||
|
try {
|
||||||
|
const result = await quickCopyContent(
|
||||||
|
citeItems,
|
||||||
|
`export=${translatorID}`,
|
||||||
|
);
|
||||||
|
text = result.text || "";
|
||||||
|
} catch {
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
if (!text.trim()) {
|
||||||
|
text = await exportViaTranslator(citeItems, translatorID);
|
||||||
|
}
|
||||||
|
if (!text.trim()) throw new Error("BibTeX 导出结果为空");
|
||||||
|
return { text, ext: "bib", mimeHint: "application/x-bibtex" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleId = formatValue.startsWith("bibliography:")
|
||||||
|
? formatValue.slice("bibliography:".length)
|
||||||
|
: formatValue;
|
||||||
|
const format = `bibliography=${styleId}`;
|
||||||
|
const result = await quickCopyContent(citeItems, format);
|
||||||
|
const text =
|
||||||
|
(result.text && result.text.trim()) ||
|
||||||
|
(result.html ? stripHtml(result.html) : "");
|
||||||
|
if (!text) throw new Error("参考文献生成结果为空(请确认已安装该样式)");
|
||||||
|
return { text, ext: "txt", mimeHint: "text/plain" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCiteToFile(
|
||||||
|
parentWin: Window,
|
||||||
|
filename: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const FilePickerCtor =
|
||||||
|
(parentWin as any).FilePicker ||
|
||||||
|
(ztoolkit.getGlobal("FilePicker" as any) as any);
|
||||||
|
if (!FilePickerCtor) {
|
||||||
|
// Fallback: copy only path unavailable
|
||||||
|
throw new Error("无法打开另存为对话框");
|
||||||
|
}
|
||||||
|
const fp = new FilePickerCtor();
|
||||||
|
fp.init(parentWin, "ChatPapers", fp.modeSave);
|
||||||
|
const isBib = /\.bib$/i.test(filename);
|
||||||
|
if (isBib) {
|
||||||
|
fp.appendFilter("BibTeX", "*.bib");
|
||||||
|
}
|
||||||
|
fp.appendFilter("Text", "*.txt");
|
||||||
|
fp.appendFilters(fp.filterAll);
|
||||||
|
fp.defaultString = filename;
|
||||||
|
|
||||||
|
const rv = await fp.show();
|
||||||
|
if (rv !== fp.returnOK && rv !== fp.returnReplace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const path = fp.file;
|
||||||
|
if (!path) return false;
|
||||||
|
await Zotero.File.putContentsAsync(path, content);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCiteFormat(options: CiteFormatOption[]): string {
|
||||||
|
if (!options.length) return "";
|
||||||
|
const bib = options.find((o) => o.band === 0);
|
||||||
|
if (bib) return bib.value;
|
||||||
|
const gbt = options.find((o) => o.band === 1);
|
||||||
|
if (gbt) return gbt.value;
|
||||||
|
return options[0].value;
|
||||||
|
}
|
||||||
@@ -2,6 +2,26 @@ import type { IconNode } from "lucide";
|
|||||||
|
|
||||||
export type LucideIcon = IconNode;
|
export type LucideIcon = IconNode;
|
||||||
|
|
||||||
|
function serializeLucideNode([tag, attrs]: IconNode[number]): string {
|
||||||
|
const parts = Object.entries(attrs)
|
||||||
|
.map(([key, value]) => `${key}="${String(value).replace(/"/g, """)}"`)
|
||||||
|
.join(" ");
|
||||||
|
return parts ? `<${tag} ${parts}/>` : `<${tag}/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lucide icon as a 16px SVG data URL for Zotero toolbar list-style-image. */
|
||||||
|
export function lucideToolbarIconUrl(icon: LucideIcon): string {
|
||||||
|
const body = icon.map(serializeLucideNode).join("");
|
||||||
|
const svg = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"',
|
||||||
|
'fill="none" stroke="context-stroke" stroke-width="2"',
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">',
|
||||||
|
body,
|
||||||
|
"</svg>",
|
||||||
|
].join("");
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLucideIcon(
|
export function createLucideIcon(
|
||||||
doc: Document,
|
doc: Document,
|
||||||
icon: LucideIcon,
|
icon: LucideIcon,
|
||||||
|
|||||||
@@ -98,14 +98,17 @@ function restoreSlots(html: string, slots: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inlineFormat(text: string): string {
|
function inlineFormat(text: string): string {
|
||||||
// Keep placeholders intact while formatting
|
// Turn literal <br> / <br> into real breaks (models often emit these)
|
||||||
let s = escapeHtml(text);
|
const BR = "%%CPBR%%";
|
||||||
// Un-escape placeholders that escapeHtml shouldn't touch anyway (%% is fine)
|
let raw = text
|
||||||
|
.replace(/<br\s*\/?>/gi, BR)
|
||||||
|
.replace(/<br\s*\/?>/gi, BR);
|
||||||
|
let s = escapeHtml(raw);
|
||||||
|
s = s.split(BR).join("<br>");
|
||||||
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||||
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
||||||
s = s.replace(/(?<!\w)\*(?!\*)([^*]+)\*(?!\w)/g, "<em>$1</em>");
|
s = s.replace(/(?<!\w)\*(?!\*)([^*]+)\*(?!\w)/g, "<em>$1</em>");
|
||||||
s = s.replace(/(?<!\w)_(?!_)([^_]+)_(?!\w)/g, "<em>$1</em>");
|
s = s.replace(/(?<!\w)_(?!_)([^_]+)_(?!\w)/g, "<em>$1</em>");
|
||||||
// inline code already extracted; leftover backticks rare
|
|
||||||
s = s.replace(
|
s = s.replace(
|
||||||
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
||||||
'<a href="$2" title="$2">$1</a>',
|
'<a href="$2" title="$2">$1</a>',
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ declare const Zotero_Tabs: {
|
|||||||
|
|
||||||
declare const PathUtils: {
|
declare const PathUtils: {
|
||||||
join: (...parts: string[]) => string;
|
join: (...parts: string[]) => string;
|
||||||
|
parent: (path: string) => string;
|
||||||
|
tempDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare const IOUtils: {
|
declare const IOUtils: {
|
||||||
exists: (path: string) => Promise<boolean>;
|
exists: (path: string) => Promise<boolean>;
|
||||||
|
read: (path: string) => Promise<Uint8Array>;
|
||||||
readUTF8: (path: string) => Promise<string>;
|
readUTF8: (path: string) => Promise<string>;
|
||||||
writeUTF8: (path: string, data: string) => Promise<void>;
|
writeUTF8: (path: string, data: string) => Promise<void>;
|
||||||
remove: (path: string) => Promise<void>;
|
remove: (path: string) => Promise<void>;
|
||||||
@@ -34,4 +37,32 @@ declare const IOUtils: {
|
|||||||
path: string,
|
path: string,
|
||||||
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
|
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
stat: (path: string) => Promise<{ size: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare const Services: {
|
||||||
|
appinfo: {
|
||||||
|
OS: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const Components: {
|
||||||
|
classes: Record<string, { createInstance: (iface: unknown) => unknown }>;
|
||||||
|
interfaces: { nsIProcess: unknown };
|
||||||
|
utils: {
|
||||||
|
isDeadWrapper: (obj: unknown) => boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MozNsIFile {
|
||||||
|
exists: () => boolean;
|
||||||
|
isExecutable?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MozNsIProcess {
|
||||||
|
init: (file: MozNsIFile) => void;
|
||||||
|
run: (blocking: boolean, args: string[], count: number) => void;
|
||||||
|
runw: (blocking: boolean, args: string[], count: number) => void;
|
||||||
|
exitValue: number;
|
||||||
|
startHidden: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,11 +24,96 @@ export type FluentMessageId =
|
|||||||
| 'chat-stopped'
|
| 'chat-stopped'
|
||||||
| 'chat-summarize'
|
| 'chat-summarize'
|
||||||
| 'chat-thinking'
|
| 'chat-thinking'
|
||||||
|
| 'cite-copied'
|
||||||
|
| 'cite-copy'
|
||||||
|
| 'cite-empty'
|
||||||
|
| 'cite-export'
|
||||||
|
| 'cite-export-cancel'
|
||||||
|
| 'cite-exported'
|
||||||
|
| 'cite-loading'
|
||||||
|
| 'cite-style'
|
||||||
|
| 'item-pane-adaptive-height'
|
||||||
|
| 'item-pane-adaptive-height-off'
|
||||||
|
| 'item-pane-adaptive-height-on'
|
||||||
|
| 'item-pane-open-window'
|
||||||
| 'item-section-chat-head'
|
| 'item-section-chat-head'
|
||||||
| 'item-section-chat-sidenav'
|
| 'item-section-chat-sidenav'
|
||||||
|
| 'item-section-lecture-head'
|
||||||
|
| 'item-section-lecture-sidenav'
|
||||||
| 'itemmenu-chat'
|
| 'itemmenu-chat'
|
||||||
| 'itemmenu-compare'
|
| 'itemmenu-compare'
|
||||||
| 'itemmenu-label'
|
| 'itemmenu-label'
|
||||||
|
| 'lecture-attach-error'
|
||||||
|
| 'lecture-attach-error-detail'
|
||||||
|
| 'lecture-beat-detail-empty'
|
||||||
|
| 'lecture-beat-label'
|
||||||
|
| 'lecture-beat-list-hint'
|
||||||
|
| 'lecture-beat-list-title'
|
||||||
|
| 'lecture-beat-tts-failed'
|
||||||
|
| 'lecture-beat-tts-pending'
|
||||||
|
| 'lecture-empty-desc'
|
||||||
|
| 'lecture-empty-title'
|
||||||
|
| 'lecture-explain-selection'
|
||||||
|
| 'lecture-explain-selection-short'
|
||||||
|
| 'lecture-granularity-paragraph'
|
||||||
|
| 'lecture-granularity-sentence'
|
||||||
|
| 'lecture-loading'
|
||||||
|
| 'lecture-mode-label'
|
||||||
|
| 'lecture-mode-overview'
|
||||||
|
| 'lecture-mode-paragraph'
|
||||||
|
| 'lecture-mount-error'
|
||||||
|
| 'lecture-no-pdf'
|
||||||
|
| 'lecture-note-saved'
|
||||||
|
| 'lecture-para-context'
|
||||||
|
| 'lecture-para-detail-empty'
|
||||||
|
| 'lecture-para-label'
|
||||||
|
| 'lecture-para-list-hint'
|
||||||
|
| 'lecture-para-original'
|
||||||
|
| 'lecture-para-play'
|
||||||
|
| 'lecture-para-translation'
|
||||||
|
| 'lecture-paragraph-list-title'
|
||||||
|
| 'lecture-pdf-file-missing'
|
||||||
|
| 'lecture-pdf-not-local'
|
||||||
|
| 'lecture-pdf-read-failed'
|
||||||
|
| 'lecture-pdf-sync-hint'
|
||||||
|
| 'lecture-platform-hint-mac'
|
||||||
|
| 'lecture-platform-hint-other'
|
||||||
|
| 'lecture-platform-hint-win'
|
||||||
|
| 'lecture-play-complete'
|
||||||
|
| 'lecture-play-next'
|
||||||
|
| 'lecture-play-pause'
|
||||||
|
| 'lecture-play-prev'
|
||||||
|
| 'lecture-play-start'
|
||||||
|
| 'lecture-playing-en'
|
||||||
|
| 'lecture-playing-zh'
|
||||||
|
| 'lecture-reprepare'
|
||||||
|
| 'lecture-save-note'
|
||||||
|
| 'lecture-selection-context'
|
||||||
|
| 'lecture-selection-done'
|
||||||
|
| 'lecture-selection-empty'
|
||||||
|
| 'lecture-selection-explaining'
|
||||||
|
| 'lecture-selection-no-item'
|
||||||
|
| 'lecture-selection-replay'
|
||||||
|
| 'lecture-selection-syntax'
|
||||||
|
| 'lecture-selection-title'
|
||||||
|
| 'lecture-sentence-label'
|
||||||
|
| 'lecture-sentence-list-hint'
|
||||||
|
| 'lecture-sentence-list-title'
|
||||||
|
| 'lecture-start'
|
||||||
|
| 'lecture-status-failed'
|
||||||
|
| 'lecture-status-idle'
|
||||||
|
| 'lecture-status-preparing'
|
||||||
|
| 'lecture-status-ready'
|
||||||
|
| 'lecture-status-ready-phase1'
|
||||||
|
| 'lecture-status-ready-step1'
|
||||||
|
| 'lecture-status-ready-step2'
|
||||||
|
| 'lecture-storage-write-failed'
|
||||||
|
| 'lecture-tts-provider'
|
||||||
|
| 'lecture-tts-test'
|
||||||
|
| 'lecture-tts-test-done'
|
||||||
|
| 'lecture-tts-testing'
|
||||||
|
| 'multi-add-none'
|
||||||
|
| 'multi-add-paper'
|
||||||
| 'multi-close'
|
| 'multi-close'
|
||||||
| 'multi-common'
|
| 'multi-common'
|
||||||
| 'multi-compare'
|
| 'multi-compare'
|
||||||
@@ -37,8 +122,11 @@ export type FluentMessageId =
|
|||||||
| 'multi-need-topic'
|
| 'multi-need-topic'
|
||||||
| 'multi-need-two'
|
| 'multi-need-two'
|
||||||
| 'multi-no-items'
|
| 'multi-no-items'
|
||||||
|
| 'multi-paper-added'
|
||||||
|
| 'multi-paper-removed'
|
||||||
| 'multi-placeholder'
|
| 'multi-placeholder'
|
||||||
| 'multi-ready'
|
| 'multi-ready'
|
||||||
|
| 'multi-remove-paper'
|
||||||
| 'multi-summary-each'
|
| 'multi-summary-each'
|
||||||
| 'multi-title'
|
| 'multi-title'
|
||||||
| 'multi-too-many'
|
| 'multi-too-many'
|
||||||
@@ -50,18 +138,24 @@ export type FluentMessageId =
|
|||||||
| 'prefs-hint-empty'
|
| 'prefs-hint-empty'
|
||||||
| 'prefs-hint-local'
|
| 'prefs-hint-local'
|
||||||
| 'prefs-hint-openrouter'
|
| 'prefs-hint-openrouter'
|
||||||
|
| 'prefs-lecture-data-dir'
|
||||||
|
| 'prefs-lecture-data-dir-hint'
|
||||||
| 'prefs-max-context'
|
| 'prefs-max-context'
|
||||||
| 'prefs-max-tokens'
|
| 'prefs-max-tokens'
|
||||||
|
| 'prefs-mineru-path'
|
||||||
| 'prefs-model'
|
| 'prefs-model'
|
||||||
| 'prefs-models-empty'
|
| 'prefs-models-empty'
|
||||||
| 'prefs-models-pick'
|
| 'prefs-models-pick'
|
||||||
| 'prefs-openrouter-referer'
|
| 'prefs-openrouter-referer'
|
||||||
| 'prefs-openrouter-title'
|
| 'prefs-openrouter-title'
|
||||||
|
| 'prefs-piper-model'
|
||||||
|
| 'prefs-piper-path'
|
||||||
| 'prefs-privacy'
|
| 'prefs-privacy'
|
||||||
| 'prefs-provider'
|
| 'prefs-provider'
|
||||||
| 'prefs-refresh-models'
|
| 'prefs-refresh-models'
|
||||||
| 'prefs-reset'
|
| 'prefs-reset'
|
||||||
| 'prefs-section-generation'
|
| 'prefs-section-generation'
|
||||||
|
| 'prefs-section-lecture'
|
||||||
| 'prefs-section-openrouter'
|
| 'prefs-section-openrouter'
|
||||||
| 'prefs-section-prompts'
|
| 'prefs-section-prompts'
|
||||||
| 'prefs-section-provider'
|
| 'prefs-section-provider'
|
||||||
@@ -71,5 +165,8 @@ export type FluentMessageId =
|
|||||||
| 'prefs-temperature'
|
| 'prefs-temperature'
|
||||||
| 'prefs-timeout'
|
| 'prefs-timeout'
|
||||||
| 'prefs-title'
|
| 'prefs-title'
|
||||||
|
| 'prefs-tts-hint-empty'
|
||||||
|
| 'prefs-tts-provider'
|
||||||
|
| 'prefs-tts-voice'
|
||||||
| 'startup-begin'
|
| 'startup-begin'
|
||||||
| 'startup-finish';
|
| 'startup-finish';
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ declare namespace _ZoteroTypes {
|
|||||||
"summaryPrompt": string;
|
"summaryPrompt": string;
|
||||||
"sendMetadata": boolean;
|
"sendMetadata": boolean;
|
||||||
"verboseLog": boolean;
|
"verboseLog": boolean;
|
||||||
|
"ttsProvider": string;
|
||||||
|
"ttsApiKey": string;
|
||||||
|
"ttsVoice": string;
|
||||||
|
"lectureTeacherStyle": string;
|
||||||
|
"mineruPath": string;
|
||||||
|
"piperPath": string;
|
||||||
|
"piperModelPath": string;
|
||||||
|
"lectureDataDir": string;
|
||||||
|
"chatPaneAdaptiveHeight": boolean;
|
||||||
|
"lecturePaneAdaptiveHeight": boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "zotero-plugin-scaffold";
|
import { defineConfig } from "zotero-plugin-scaffold";
|
||||||
import pkg from "./package.json";
|
import pkg from "./package.json";
|
||||||
|
import { fixToolkitImportESModule } from "./scripts/esbuild-fix-toolkit-import.mjs";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
source: ["src", "addon"],
|
source: ["src", "addon"],
|
||||||
@@ -35,6 +36,7 @@ export default defineConfig({
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
target: "firefox115",
|
target: "firefox115",
|
||||||
outfile: `.scaffold/build/addon/content/scripts/${pkg.config.addonRef}.js`,
|
outfile: `.scaffold/build/addon/content/scripts/${pkg.config.addonRef}.js`,
|
||||||
|
plugins: [fixToolkitImportESModule()],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||