From c46d2476651140f75f3b400c416cf97f857ac9c3 Mon Sep 17 00:00:00 2001 From: yehongyu Date: Mon, 20 Jul 2026 15:22:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8D=95=E7=AF=87=E5=92=8C?= =?UTF-8?q?=E5=A4=9A=E7=AF=87=E8=AE=BA=E6=96=87=E7=9A=84=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E6=80=BB=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/content/chatpapers.css | 488 +++++++++++++++++++++++++++--- addon/locale/en-US/addon.ftl | 24 +- addon/locale/en-US/mainWindow.ftl | 2 + addon/locale/zh-CN/addon.ftl | 25 +- addon/locale/zh-CN/mainWindow.ftl | 2 + addon/prefs.js | 2 +- package-lock.json | 41 +++ package.json | 7 +- src/hooks.ts | 4 + src/modules/llm/history.ts | 76 +++++ src/modules/llm/prompts.ts | 17 +- src/modules/pdf/context.ts | 96 ++++-- src/modules/pdf/extractor.ts | 266 +++++++++++++--- src/modules/pdf/multiContext.ts | 235 ++++++++++++++ src/modules/pdf/selection.ts | 213 +++++++++++++ src/modules/ui/chatView.ts | 303 ++++++++++++++----- src/modules/ui/itemMenu.ts | 51 ++++ src/modules/ui/multiChatDialog.ts | 212 +++++++++++++ src/modules/ui/multiChatView.ts | 423 ++++++++++++++++++++++++++ src/modules/zotero/notes.ts | 90 +----- src/utils/icons.ts | 41 +++ src/utils/markdown.ts | 340 +++++++++++++++++++++ typings/i10n.d.ts | 20 ++ 23 files changed, 2710 insertions(+), 268 deletions(-) create mode 100644 src/modules/llm/history.ts create mode 100644 src/modules/pdf/multiContext.ts create mode 100644 src/modules/pdf/selection.ts create mode 100644 src/modules/ui/itemMenu.ts create mode 100644 src/modules/ui/multiChatDialog.ts create mode 100644 src/modules/ui/multiChatView.ts create mode 100644 src/utils/icons.ts create mode 100644 src/utils/markdown.ts diff --git a/addon/content/chatpapers.css b/addon/content/chatpapers.css index 56e610e..851e783 100644 --- a/addon/content/chatpapers.css +++ b/addon/content/chatpapers.css @@ -6,12 +6,33 @@ min-height: 320px; box-sizing: border-box; font-size: 13px; + -moz-user-select: text; + user-select: text; } .chatpapers-header { display: flex; flex-direction: column; - gap: 2px; + gap: 4px; +} + +.chatpapers-brand { + display: flex; + align-items: center; + gap: 6px; + color: #2563eb; + font-weight: 700; + font-size: 13px; +} + +.chatpapers-brand-icon { + flex-shrink: 0; +} + +.chatpapers-title-row { + display: flex; + align-items: flex-start; + gap: 6px; } .chatpapers-title { @@ -20,54 +41,92 @@ } .chatpapers-meta { + display: flex; + align-items: center; + gap: 5px; opacity: 0.7; font-size: 12px; } -.chatpapers-messages { - flex: 1; - min-height: 180px; - max-height: 420px; - overflow: auto; - border: 1px solid color-mix(in srgb, currentColor 18%, transparent); - border-radius: 8px; - padding: 8px; +.chatpapers-meta-icon { + flex-shrink: 0; + opacity: 0.85; +} + +.chatpapers-empty { display: flex; flex-direction: column; - gap: 8px; - background: color-mix(in srgb, currentColor 4%, transparent); + align-items: center; + justify-content: center; + gap: 6px; + padding: 28px 16px; + text-align: center; + opacity: 0.72; } -.chatpapers-bubble { - padding: 8px 10px; - border-radius: 8px; - white-space: pre-wrap; - word-break: break-word; -} - -.chatpapers-user { - background: color-mix(in srgb, #3b82f6 18%, transparent); - align-self: flex-end; - max-width: 92%; -} - -.chatpapers-assistant { - background: color-mix(in srgb, currentColor 8%, transparent); - align-self: flex-start; - max-width: 96%; -} - -.chatpapers-role { - font-size: 11px; - opacity: 0.65; +.chatpapers-empty-icon { + color: #2563eb; + opacity: 0.85; margin-bottom: 4px; +} + +.chatpapers-empty-title { font-weight: 600; + font-size: 13px; +} + +.chatpapers-empty-desc { + font-size: 12px; + max-width: 220px; + line-height: 1.4; +} + +.chatpapers-icon { + display: block; + flex-shrink: 0; +} + +.chatpapers-role-left { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.chatpapers-role-icon { + opacity: 0.9; +} + +.chatpapers-copy { + appearance: none; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: color-mix(in srgb, currentColor 6%, transparent); + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + cursor: pointer; + color: inherit; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.chatpapers-copy:hover { + background: color-mix(in srgb, currentColor 14%, transparent); } .chatpapers-status { min-height: 1.2em; font-size: 12px; opacity: 0.85; + display: flex; + align-items: flex-start; + gap: 5px; +} + +.chatpapers-status-icon { + display: inline-flex; + margin-top: 1px; + flex-shrink: 0; } .chatpapers-status[data-kind="warn"] { @@ -78,6 +137,10 @@ color: #b91c1c; } +.chatpapers-status[data-kind="info"] .chatpapers-status-icon { + color: #2563eb; +} + .chatpapers-toolbar, .chatpapers-actions { display: flex; @@ -85,14 +148,28 @@ gap: 6px; } +.chatpapers-composer { + display: flex; + flex-direction: column; + gap: 6px; +} + .chatpapers-btn { appearance: none; border: 1px solid color-mix(in srgb, currentColor 22%, transparent); background: color-mix(in srgb, currentColor 6%, transparent); border-radius: 6px; - padding: 4px 10px; + padding: 5px 10px; cursor: pointer; font: inherit; + display: inline-flex; + align-items: center; + gap: 6px; + line-height: 1; +} + +.chatpapers-btn-label { + line-height: 1.2; } .chatpapers-btn:hover:not(:disabled) { @@ -114,6 +191,261 @@ background: #1d4ed8; } +.chatpapers-messages { + flex: 1; + min-height: 180px; + max-height: 420px; + overflow: auto; + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + background: color-mix(in srgb, currentColor 4%, transparent); + -moz-user-select: text !important; + user-select: text !important; + cursor: text; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent; +} + +.chatpapers-messages::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.chatpapers-messages::-webkit-scrollbar-thumb { + background: color-mix(in srgb, currentColor 32%, transparent); + border-radius: 999px; +} + +.chatpapers-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chatpapers-bubble { + padding: 8px 10px; + border-radius: 8px; + word-break: break-word; + overflow-wrap: anywhere; + -moz-user-select: text !important; + user-select: text !important; + cursor: text; +} + +.chatpapers-bubble .chatpapers-table-wrap { + word-break: normal; + overflow-wrap: normal; +} + +.chatpapers-content { + -moz-user-select: text !important; + user-select: text !important; + cursor: text; + white-space: pre-wrap; +} + +.chatpapers-content.chatpapers-md { + white-space: normal; + line-height: 1.4; +} + +.chatpapers-md > :first-child { + margin-top: 0 !important; +} + +.chatpapers-md > :last-child { + margin-bottom: 0 !important; +} + +.chatpapers-md h1, +.chatpapers-md h2, +.chatpapers-md h3, +.chatpapers-md h4 { + margin: 0.45em 0 0.15em; + line-height: 1.25; + font-weight: 700; +} + +.chatpapers-md h1 { + font-size: 1.15em; +} + +.chatpapers-md h2 { + font-size: 1.08em; +} + +.chatpapers-md h3, +.chatpapers-md h4 { + font-size: 1.02em; +} + +.chatpapers-md p { + margin: 0.2em 0; +} + +.chatpapers-md ul, +.chatpapers-md ol { + margin: 0.2em 0; + padding-left: 1.35em; +} + +.chatpapers-md li { + margin: 0; +} + +.chatpapers-md li + li { + margin-top: 0.1em; +} + +.chatpapers-md code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + padding: 0.05em 0.3em; + border-radius: 4px; + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.chatpapers-md pre { + margin: 0.35em 0; + padding: 6px 8px; + border-radius: 6px; + overflow: auto; + background: color-mix(in srgb, currentColor 8%, transparent); + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); +} + +.chatpapers-md pre code { + padding: 0; + background: transparent; + font-size: 0.88em; +} + +.chatpapers-md blockquote { + margin: 0.3em 0; + padding: 0 0.65em; + border-left: 3px solid color-mix(in srgb, currentColor 35%, transparent); + opacity: 0.92; +} + +.chatpapers-md blockquote p { + margin: 0.15em 0; +} + +.chatpapers-md hr { + border: none; + border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent); + margin: 0.4em 0; +} + +.chatpapers-md a { + color: #2563eb; + text-decoration: underline; +} + +.chatpapers-md strong { + font-weight: 700; +} + +.chatpapers-md em { + font-style: italic; +} + +.chatpapers-table-wrap { + display: block; + width: 100%; + max-width: 100%; + overflow-x: auto; + margin: 0.45em 0; + -webkit-overflow-scrolling: touch; +} + +.chatpapers-md table.chatpapers-table, +.chatpapers-md .chatpapers-table { + width: max-content; + min-width: 100%; + max-width: none; + border-collapse: collapse; + table-layout: auto; + font-size: 0.92em; + line-height: 1.35; + word-break: normal; + overflow-wrap: normal; +} + +.chatpapers-md .chatpapers-table th, +.chatpapers-md .chatpapers-table td { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + padding: 0.35em 0.55em; + vertical-align: top; + white-space: normal; + word-break: break-word; +} + +.chatpapers-md .chatpapers-table th { + font-weight: 700; + background: color-mix(in srgb, currentColor 10%, transparent); + text-align: left; +} + +.chatpapers-md .chatpapers-table tbody tr:nth-child(even) td { + background: color-mix(in srgb, currentColor 4%, transparent); +} + +.chatpapers-math-inline { + display: inline; + vertical-align: middle; +} + +.chatpapers-math-block { + display: block; + margin: 0.35em 0; + overflow-x: auto; + text-align: center; +} + +.chatpapers-math math { + font-size: 1.05em; +} + +.chatpapers-math-block math { + font-size: 1.1em; +} + +.chatpapers-tex { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + padding: 0.05em 0.3em; + border-radius: 4px; + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.chatpapers-user { + background: color-mix(in srgb, #3b82f6 18%, transparent); + align-self: flex-end; + max-width: 92%; +} + +.chatpapers-assistant { + background: color-mix(in srgb, currentColor 8%, transparent); + align-self: flex-start; + max-width: 96%; +} + +.chatpapers-role { + font-size: 11px; + opacity: 0.65; + margin-bottom: 4px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + -moz-user-select: none; + user-select: none; +} + .chatpapers-input { width: 100%; box-sizing: border-box; @@ -125,6 +457,8 @@ font: inherit; background: transparent; color: inherit; + -moz-user-select: text; + user-select: text; } .chatpapers-loading { @@ -138,3 +472,89 @@ margin: 4px 0 10px; max-width: 52em; } + +.chatpapers-multi-root { + height: 100%; + min-height: 0; + max-height: 100%; + overflow: hidden; + gap: 6px; + padding: 10px 12px 12px; +} + +.chatpapers-multi-root .chatpapers-header, +.chatpapers-multi-root .chatpapers-paper-chips, +.chatpapers-multi-root .chatpapers-status, +.chatpapers-multi-root .chatpapers-toolbar, +.chatpapers-multi-root .chatpapers-composer { + flex-shrink: 0; +} + +.chatpapers-multi-root .chatpapers-messages { + flex: 1 1 auto; + min-height: 140px; + max-height: none; + height: auto; +} + +.chatpapers-multi-root .chatpapers-empty-desc { + max-width: 420px; +} + +.chatpapers-multi-root .chatpapers-composer { + margin-top: 2px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); +} + +.chatpapers-multi-root .chatpapers-toolbar { + padding-top: 2px; +} + +/* Dialog chrome: hide empty footer button area; thin scrollbars */ +#dialog-button-box, +.dialog-button-box { + display: none !important; +} + +.chatpapers-table-wrap, +.chatpapers-multi-root { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent; +} + +.chatpapers-table-wrap::-webkit-scrollbar, +.chatpapers-multi-root::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.chatpapers-table-wrap::-webkit-scrollbar-thumb, +.chatpapers-multi-root::-webkit-scrollbar-thumb { + background: color-mix(in srgb, currentColor 32%, transparent); + border-radius: 999px; +} + +.chatpapers-paper-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 72px; + overflow: auto; + margin-bottom: 2px; + scrollbar-width: thin; +} + +.chatpapers-chip { + display: inline-block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + background: color-mix(in srgb, #2563eb 10%, transparent); + color: inherit; +} diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index 222dbe2..99b493e 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -21,4 +21,26 @@ chat-cleared = Chat cleared chat-no-reply = No AI reply to save yet chat-note-saved = Saved as note chat-selection-added = Selection added to next message -chat-selection-empty = No text selected in the reader +chat-selection-empty = No text selected. Select text in the PDF first, then click this button (do not click elsewhere in between). +chat-copy = Copy +chat-copied = Copied to clipboard +chat-empty-title = Start a conversation +chat-empty-desc = Summarize this paper or ask a question about methods, results, and limitations. + +itemmenu-chat = ChatPapers: Open chat +itemmenu-compare = ChatPapers: Compare papers + +multi-title = Multi-paper chat +multi-empty-title = Read papers together +multi-empty-desc = Enter a research topic and click Topic review for writing-ready notes; or compare, summarize each, or ask freely. +multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation +multi-topic-review = Topic review +multi-need-topic = Enter a research topic in the box, then click Topic review +multi-compare = Compare +multi-summary-each = Summarize each +multi-common = Common themes +multi-close = Close +multi-no-items = Please select items first +multi-need-two = Select at least 2 items to compare +multi-too-many = { $count } items selected; using the first 8 +multi-ready = { $count } papers ready for topic review diff --git a/addon/locale/en-US/mainWindow.ftl b/addon/locale/en-US/mainWindow.ftl index ba411f3..6e5ca86 100644 --- a/addon/locale/en-US/mainWindow.ftl +++ b/addon/locale/en-US/mainWindow.ftl @@ -1 +1,3 @@ itemmenu-label = ChatPapers: Summarize +itemmenu-chat = ChatPapers: Open chat +itemmenu-compare = ChatPapers: Compare papers diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index 707826f..fa3aed7 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -21,4 +21,27 @@ chat-cleared = 已清空对话 chat-no-reply = 还没有可保存的 AI 回复 chat-note-saved = 已保存为笔记 chat-selection-added = 已将选区加入下一条消息 -chat-selection-empty = 阅读器中没有选中文本 +chat-selection-empty = 未检测到选区。请先在 PDF 中拖选文字,再直接点「添加选区」(中间不要点别处)。 +chat-copy = 复制 +chat-copied = 已复制到剪贴板 +chat-empty-title = 开始对话 +chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。 + +itemmenu-chat = ChatPapers:打开对话 +itemmenu-compare = ChatPapers:多篇对照 + +multi-title = 多篇对照 +multi-empty-title = 多论文整体阅读 +multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。 +multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用 +multi-topic-review = 主题综述 +multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」 +multi-compare = 对比差异 +multi-summary-each = 各自摘要 +multi-common = 共同主题 +multi-close = 关闭 +multi-no-items = 请先选择文献 +multi-need-two = 请至少选择 2 篇文献再对照 +multi-too-many = 已选择 { $count } 篇,将使用前 8 篇 +multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述 + diff --git a/addon/locale/zh-CN/mainWindow.ftl b/addon/locale/zh-CN/mainWindow.ftl index ae057d0..158a7ab 100644 --- a/addon/locale/zh-CN/mainWindow.ftl +++ b/addon/locale/zh-CN/mainWindow.ftl @@ -1 +1,3 @@ itemmenu-label = ChatPapers:总结 +itemmenu-chat = ChatPapers:打开对话 +itemmenu-compare = ChatPapers:多篇对照 diff --git a/addon/prefs.js b/addon/prefs.js index 05551f9..9cc9ccd 100644 --- a/addon/prefs.js +++ b/addon/prefs.js @@ -7,7 +7,7 @@ pref("openrouterTitle", "ChatPapers"); pref("temperature", "0.3"); pref("maxTokens", 2048); pref("timeoutMs", 120000); -pref("maxContextChars", 80000); +pref("maxContextChars", 100000); pref("answerLanguage", "zh-CN"); pref("systemPrompt", ""); pref("summaryPrompt", ""); diff --git a/package-lock.json b/package-lock.json index 22aa03e..5725a7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { + "katex": "^0.18.1", + "lucide": "^1.25.0", "zotero-plugin-toolkit": "^5.1.0-beta.13" }, "devDependencies": { + "@types/katex": "^0.16.8", "@types/node": "^24.10.0", "@zotero-plugin/eslint-config": "^0.6.7", "eslint": "^9.39.2", @@ -1360,6 +1363,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", @@ -2860,6 +2870,31 @@ "setimmediate": "^1.0.5" } }, + "node_modules/katex": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.1.tgz", + "integrity": "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2917,6 +2952,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.25.0.tgz", + "integrity": "sha512-Pg/9Ga1xTbrnI6GY/7J9krIYmJIkAf3PkK6wPmsAeUtAQsBdGxs1+dfvLqjf9nPI5aFm9+VquT75JGpO3QXSpQ==", + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index 6e3ab1a..111ae06 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,12 @@ "update-deps": "npm update --save" }, "dependencies": { + "katex": "^0.18.1", + "lucide": "^1.25.0", "zotero-plugin-toolkit": "^5.1.0-beta.13" }, "devDependencies": { + "@types/katex": "^0.16.8", "@types/node": "^24.10.0", "@zotero-plugin/eslint-config": "^0.6.7", "eslint": "^9.39.2", @@ -46,7 +49,9 @@ "endOfLine": "lf", "overrides": [ { - "files": ["*.xhtml"], + "files": [ + "*.xhtml" + ], "options": { "htmlWhitespaceSensitivity": "css" } diff --git a/src/hooks.ts b/src/hooks.ts index c63e7a4..b597850 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -2,6 +2,8 @@ import { initLocale } from "./utils/locale"; import { createZToolkit } from "./utils/ztoolkit"; import { registerChatPane, registerPrefs } from "./modules/ui/readerPane"; import { registerPrefsScripts } from "./modules/ui/prefs"; +import { registerItemMenus } from "./modules/ui/itemMenu"; +import { registerReaderSelectionHook } from "./modules/pdf/selection"; async function onStartup() { await Promise.all([ @@ -13,6 +15,7 @@ async function onStartup() { initLocale(); registerPrefs(); registerChatPane(); + registerReaderSelectionHook(); await Promise.all( Zotero.getMainWindows().map((win) => onMainWindowLoad(win)), @@ -26,6 +29,7 @@ async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise { win.MozXULElement.insertFTLIfNeeded( `${addon.data.config.addonRef}-mainWindow.ftl`, ); + registerItemMenus(); } async function onMainWindowUnload(_win: Window): Promise { diff --git a/src/modules/llm/history.ts b/src/modules/llm/history.ts new file mode 100644 index 0000000..f177e69 --- /dev/null +++ b/src/modules/llm/history.ts @@ -0,0 +1,76 @@ +import type { ChatMessage } from "../llm/types"; + +/** Detect messages that already embed full paper dumps (should not be in UI history). */ +export function isPaperDump(content: string): boolean { + return ( + content.includes("【论文正文】") || + content.includes("【论文 1】") || + content.startsWith("【论文元数据】") || + content.startsWith("已收到论文内容") || + content.includes("【说明】以上为本次会话的论文材料") + ); +} + +/** + * Keep short Q&A turns for the model. Truncate very long assistant replies + * so paper context + latest question are less likely to be dropped. + */ +export function sanitizeChatHistory( + history: ChatMessage[], + options?: { maxMessages?: number; maxAssistantChars?: number }, +): ChatMessage[] { + const maxMessages = options?.maxMessages ?? 10; + const maxAssistantChars = options?.maxAssistantChars ?? 5000; + + const cleaned = history + .filter((m) => m.role === "user" || m.role === "assistant") + .filter((m) => !isPaperDump(m.content)) + .map((m) => { + if ( + m.role === "assistant" && + m.content && + m.content.length > maxAssistantChars + ) { + return { + ...m, + content: + m.content.slice(0, maxAssistantChars) + + "\n\n…(为保留论文上下文,历史回复已截断)", + }; + } + return m; + }); + + return cleaned.slice(-maxMessages); +} + +/** + * Pin paper corpus near the start of the request, then prior Q&A, then the + * current task. Avoids stuffing full papers only into the latest message + * (which providers often truncate under context pressure on turn 2+). + */ +export function assemblePaperChatMessages(options: { + system: string; + paperBlock: string; + taskBlock: string; + history: ChatMessage[]; +}): ChatMessage[] { + const messages: ChatMessage[] = [ + { role: "system", content: options.system }, + { + role: "user", + content: `${options.paperBlock} + +【说明】以上为本次会话的论文材料。后续所有问题与分析任务均基于这些论文;无需用户再次提供全文或文本。`, + }, + { + role: "assistant", + content: + "已收到并理解以上论文材料。请继续提问或下达分析任务,我将基于这些论文作答,不会要求你再次提供全文。", + }, + ]; + + messages.push(...sanitizeChatHistory(options.history)); + messages.push({ role: "user", content: options.taskBlock }); + return messages; +} diff --git a/src/modules/llm/prompts.ts b/src/modules/llm/prompts.ts index d65c67f..43022e4 100644 --- a/src/modules/llm/prompts.ts +++ b/src/modules/llm/prompts.ts @@ -1,20 +1,23 @@ import { getPref } from "../../utils/prefs"; -const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题。 +const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。论文材料会在会话开始时提供,并在整个对话中持续有效。 要求: -1. 回答准确、简洁,优先使用论文原文依据。 -2. 若信息不足,请明确说明,不要编造。 -3. 尽量标注页码或引用短片段(若上下文含页分隔)。 -4. 使用用户偏好的语言回答。`; +1. 始终把已提供的【论文正文】/【论文 N】视为可用材料,直接据此回答或总结。 +2. 多轮对话时结合此前问答继续深入,不要假装没有论文或要求用户重新粘贴文本。 +3. 回答准确、简洁,优先引用正文依据;不要编造未出现的实验数据。 +4. 禁止要求用户「重新提供论文」或声称「只有参考文献/片段」。 +5. 使用用户偏好的语言回答。`; -const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含: +const DEFAULT_SUMMARY = `请对【论文正文】做结构化总结,使用 Markdown,包含: ## 一句话概括 ## 研究问题 / 动机 ## 方法 ## 主要结果 ## 贡献与创新 ## 局限与可追问点 -## 关键术语(可选)`; +## 关键术语(可选) + +注意:直接输出总结内容,不要先解释「文本是否完整」。`; export function getSystemPrompt(): string { const custom = (getPref("systemPrompt") || "").trim(); diff --git a/src/modules/pdf/context.ts b/src/modules/pdf/context.ts index bba7086..6873783 100644 --- a/src/modules/pdf/context.ts +++ b/src/modules/pdf/context.ts @@ -1,5 +1,6 @@ import type { ChatMessage } from "../llm/types"; import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts"; +import { assemblePaperChatMessages } from "../llm/history"; import { buildMetadataBlock, extractPdfContext, @@ -10,6 +11,7 @@ export interface BuiltContext { messages: ChatMessage[]; extract: ExtractResult; warning?: string; + ready: boolean; } export async function buildChatMessages(options: { @@ -23,55 +25,87 @@ export async function buildChatMessages(options: { const warnings: string[] = []; if (!extract.text) { + return { + messages: [], + extract, + warning: + extract.error || + "未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。", + ready: false, + }; + } + + const rawInfo = + extract.rawLength && extract.rawLength !== extract.text.length + ? `(原文 ${extract.rawLength} → 提交 ${extract.text.length})` + : ""; + + if (extract.truncated) { warnings.push( - "未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。", + `已智能截断并优先保留正文${rawInfo},来源 ${extract.source}${ + extract.pageCount ? ` · ${extract.pageCount} 页` : "" + }。`, + ); + } else { + warnings.push( + `已载入论文文本 ${extract.text.length} 字符(${extract.source}${ + extract.pageCount ? ` · ${extract.pageCount} 页` : "" + })。`, ); - } else if (extract.truncated) { - warnings.push("论文较长,已按设置截断上下文。"); } const meta = extract.parentItem ? buildMetadataBlock(extract.parentItem) : ""; - const systemParts = [getSystemPrompt()]; - if (meta) { - systemParts.push(`论文元数据:\n${meta}`); - } - if (extract.text) { - systemParts.push( - `论文正文(页之间可能以换页符分隔):\n${extract.text}`, - ); - } - - const messages: ChatMessage[] = [ - { role: "system", content: systemParts.join("\n\n") }, - ]; - - // Keep recent history (exclude old system) - const history = options.history - .filter((m) => m.role !== "system") - .slice(-20); - messages.push(...history); + const paperBlock = [ + meta ? `【论文元数据】\n${meta}` : "", + `【论文正文】\n${extract.text}`, + ] + .filter(Boolean) + .join("\n\n"); + let taskBlock: string; if (options.mode === "summary") { - const summaryAsk = getSummaryPrompt(); - messages.push({ - role: "user", - content: summaryAsk, - }); + taskBlock = `【任务】 +请直接基于本会话开头已提供的【论文正文】完成结构化总结。 +严禁回复「请提供论文」「文本不完整无法总结」等推脱话术;信息不足时也请基于已有正文尽力总结,并简要注明局限。 + +${getSummaryPrompt()}`; } else { - let content = options.userText.trim(); + let question = options.userText.trim(); if (options.selection?.trim()) { - content = - `【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`; + question = `【选中原文】\n${options.selection.trim()}\n\n【问题】\n${question}`; + } else { + question = `【问题】\n${question}`; } - messages.push({ role: "user", content }); + taskBlock = `【任务】 +请直接基于本会话开头已提供的论文材料回答。 +严禁要求用户再次提供论文全文。 + +${question}`; } + const system = `${getSystemPrompt()} + +硬性规则(必须遵守): +1. 论文材料已在本会话首条用户消息中提供(【论文正文】);后续每一轮提问仍以该材料为准。 +2. 绝对禁止说「请提供论文文本」「只有引用/片段无法总结」之类的话。 +3. 若正文有截断标记,仍须基于已给出的章节作答,并可选一句说明「部分中间内容经抽样」。 +4. 结合此前对话继续深入回答,不要假装这是全新的、没有论文的会话。 +5. 不要把参考文献列表误判为「没有正文」。`; + + const messages = assemblePaperChatMessages({ + system, + paperBlock, + taskBlock, + history: options.history, + }); + return { messages, extract, warning: warnings.length ? warnings.join(" ") : undefined, + ready: true, }; } diff --git a/src/modules/pdf/extractor.ts b/src/modules/pdf/extractor.ts index 9ab77f5..c473acc 100644 --- a/src/modules/pdf/extractor.ts +++ b/src/modules/pdf/extractor.ts @@ -4,29 +4,55 @@ export interface ExtractResult { text: string; truncated: boolean; pageCount?: number; - source: "pdfworker" | "fulltext" | "empty"; + source: "pdfworker" | "attachmentText" | "reader" | "empty"; attachment?: Zotero.Item; parentItem?: Zotero.Item; + error?: string; + /** Original extracted length before trim/truncate */ + rawLength?: number; + /** Length after dropping references */ + bodyLength?: number; } -function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined { - if (item.isAttachment() && item.attachmentContentType === "application/pdf") { - return item; +function resolvePDFWorker(): any { + const candidates = [ + (Zotero as any).PDFWorker, + (globalThis as any).Zotero?.PDFWorker, + ]; + try { + candidates.push(ztoolkit.getGlobal("Zotero" as any)?.PDFWorker); + } catch { + // ignore } - if (item.isRegularItem()) { - const attachments = item.getAttachments().map((id) => Zotero.Items.get(id)); - return attachments.find( - (att) => att?.isAttachment() && att.attachmentContentType === "application/pdf", - ); + try { + const win = Zotero.getMainWindow?.() as any; + if (win?.Zotero?.PDFWorker) candidates.push(win.Zotero.PDFWorker); + } catch { + // ignore } - // Child attachment of a parent - if (item.isAttachment() && item.parentItemID) { - const parent = Zotero.Items.get(item.parentItemID); - return findPdfAttachment(parent); + for (const worker of candidates) { + if (worker?.getFullText) return worker; } return undefined; } +function isPdfAttachment(item: any): boolean { + if (!item || typeof item.isAttachment !== "function") return false; + if (!item.isAttachment()) return false; + try { + if (typeof item.isPDFAttachment === "function") { + return !!item.isPDFAttachment(); + } + } catch { + // fall through + } + const type = String(item.attachmentContentType || "").toLowerCase(); + const name = String( + item.attachmentFilename || item.getField?.("title") || "", + ).toLowerCase(); + return type === "application/pdf" || name.endsWith(".pdf"); +} + function getParentItem(item: Zotero.Item): Zotero.Item { if (item.isRegularItem()) return item; if (item.parentItemID) { @@ -35,25 +61,127 @@ function getParentItem(item: Zotero.Item): Zotero.Item { return item; } +/** Resolve the best PDF attachment for the current pane item / open reader. */ +export function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined { + if (isPdfAttachment(item)) return item; + + try { + const readers = + (Zotero.Reader as any).getAll?.() || + (Zotero.Reader as any)._readers || + []; + for (const reader of readers) { + const attID = reader?.itemID || reader?._itemID; + if (!attID) continue; + const att = Zotero.Items.get(attID) as Zotero.Item | false; + if (!att || !isPdfAttachment(att)) continue; + if ( + att.id === item.id || + att.parentItemID === item.id || + (item.parentItemID && att.parentItemID === item.parentItemID) + ) { + return att; + } + } + } catch (e) { + ztoolkit.log("findPdfAttachment reader scan failed", e); + } + + if (item.isRegularItem()) { + const ids = item.getAttachments(); + for (const id of ids) { + const att = Zotero.Items.get(id) as Zotero.Item | false; + if (att && isPdfAttachment(att)) return att; + } + } + + if (item.isAttachment() && item.parentItemID) { + const parent = Zotero.Items.get(item.parentItemID) as Zotero.Item | false; + if (parent && parent.isRegularItem()) { + return findPdfAttachment(parent); + } + } + + return undefined; +} + +function normalizeExtractedText(raw: unknown): string { + if (raw == null) return ""; + if (typeof raw === "string") return raw.trim(); + if (Array.isArray(raw)) return raw.join("").trim(); + if (typeof raw === "object" && raw !== null && "text" in (raw as object)) { + return normalizeExtractedText((raw as { text: unknown }).text); + } + return String(raw).trim(); +} + async function extractViaPdfWorker(attachment: Zotero.Item): Promise { - const worker = (Zotero as any).PDFWorker; + const worker = resolvePDFWorker(); if (!worker?.getFullText) { throw new Error("PDFWorker unavailable"); } - const result = await worker.getFullText(attachment.id, null, true); - return (result?.text || "").trim(); -} -async function extractViaFulltext(attachment: Zotero.Item): Promise { - try { - const text = (attachment as any).attachmentText; - if (typeof text === "string" && text.trim()) return text.trim(); - } catch { - // ignore + const attempts: Array<() => Promise> = [ + () => worker.getFullText(attachment.id), + () => worker.getFullText(attachment.id, null, true), + () => worker.getFullText(attachment.id, true), + ]; + + let lastError: unknown; + for (const run of attempts) { + try { + const result = await run(); + const text = normalizeExtractedText(result); + if (text) return text; + if (result != null) return ""; + } catch (e) { + lastError = e; + } } + if (lastError) throw lastError; return ""; } +async function extractViaAttachmentText(attachment: Zotero.Item): Promise { + const maybe = (attachment as any).attachmentText; + const value = typeof maybe?.then === "function" ? await maybe : maybe; + return normalizeExtractedText(value); +} + +/** + * Drop bibliography / references tails so truncation does not keep + * "title+abstract + references" while dropping methods/results. + */ +export function stripReferencesSection(text: string): { + body: string; + removedRefs: boolean; +} { + const patterns = [ + /\n[ \t]*(References|REFERENCES|Bibliography|BIBLIOGRAPHY|Works Cited|WORKS CITED)\s*\n/, + /\n[ \t]*(参考文献|引用文献|参考书目)\s*\n/, + ]; + + let cut = -1; + for (const re of patterns) { + const m = re.exec(text); + if (!m || m.index == null) continue; + // Only treat as refs section if it appears in the latter half + if (m.index > text.length * 0.45) { + cut = cut < 0 ? m.index : Math.min(cut, m.index); + } + } + + if (cut < 0) return { body: text, removedRefs: false }; + return { + body: text.slice(0, cut).trimEnd(), + removedRefs: true, + }; +} + +/** + * Prefer intro + evenly sampled body + conclusion. + * Avoid old head+tail strategy (tail is often References). + */ export function truncateText( text: string, maxChars: number, @@ -61,13 +189,54 @@ export function truncateText( if (text.length <= maxChars) { return { text, truncated: false }; } - const head = Math.floor(maxChars * 0.7); - const tail = maxChars - head - 80; - const sliced = - text.slice(0, head) + - "\n\n[... 中间内容已截断 ...]\n\n" + - text.slice(-Math.max(tail, 0)); - return { text: sliced, truncated: true }; + + const { body, removedRefs } = stripReferencesSection(text); + const source = body.length > 1000 ? body : text; + + if (source.length <= maxChars) { + const note = removedRefs + ? "\n\n[注:已省略文末 References/参考文献 以保留正文。]" + : ""; + return { text: source + note, truncated: removedRefs }; + } + + // Budget: 40% head (title/abstract/intro), 45% middle samples, 15% tail (conclusion) + const headBudget = Math.floor(maxChars * 0.4); + const midBudget = Math.floor(maxChars * 0.45); + const tailBudget = Math.max(maxChars - headBudget - midBudget - 120, 800); + + const head = source.slice(0, headBudget); + const tail = source.slice(-tailBudget); + const midRegionStart = headBudget; + const midRegionEnd = Math.max(midRegionStart, source.length - tailBudget); + const midRegion = source.slice(midRegionStart, midRegionEnd); + + const chunks: string[] = []; + const chunkCount = 4; + const chunkSize = Math.floor(midBudget / chunkCount); + if (midRegion.length > 0 && chunkSize > 0) { + for (let i = 0; i < chunkCount; i++) { + const start = Math.floor((midRegion.length * i) / chunkCount); + chunks.push(midRegion.slice(start, start + chunkSize)); + } + } + + const sliced = [ + head, + "", + "[... 中间部分抽样保留(方法/实验/结果)...]", + "", + chunks.filter(Boolean).join("\n\n[...]\n\n"), + "", + "[... 后文(结论附近)...]", + "", + tail, + removedRefs ? "[注:已省略文末 References/参考文献。]" : "", + ] + .filter((s, idx, arr) => !(s === "" && arr[idx - 1] === "")) + .join("\n"); + + return { text: sliced.slice(0, maxChars), truncated: true }; } export async function extractPdfContext(item: Zotero.Item): Promise { @@ -79,30 +248,52 @@ export async function extractPdfContext(item: Zotero.Item): Promise [c.firstName, c.lastName].filter(Boolean).join(" ")) .filter(Boolean) .join(", "); diff --git a/src/modules/pdf/multiContext.ts b/src/modules/pdf/multiContext.ts new file mode 100644 index 0000000..4d5db33 --- /dev/null +++ b/src/modules/pdf/multiContext.ts @@ -0,0 +1,235 @@ +import { getPref } from "../../utils/prefs"; +import type { ChatMessage } from "../llm/types"; +import { getSystemPrompt } from "../llm/prompts"; +import { assemblePaperChatMessages } from "../llm/history"; +import { + buildMetadataBlock, + extractPdfContext, + truncateText, + type ExtractResult, +} from "./extractor"; + +export interface MultiPaperExtract { + item: Zotero.Item; + title: string; + extract: ExtractResult; +} + +export interface MultiBuiltContext { + messages: ChatMessage[]; + papers: MultiPaperExtract[]; + warning?: string; + ready: boolean; +} + +export type MultiChatMode = + | "chat" + | "compare" + | "summary-each" + | "common-themes" + | "topic-review"; + +const MODE_PROMPTS: Record< + Exclude, + string +> = { + compare: `请对比以上多篇论文,使用 Markdown 输出: +## 研究问题对比 +## 方法差异 +## 主要结果差异 +## 贡献与侧重点 +## 适用场景建议 +## 一句话总结差异 + +要求:按论文标题指称各文;突出差异与互补,避免空泛套话。对比表请用标准 Markdown 表格(含表头分隔行)。`, + "summary-each": `请对以上每篇论文各写一段简要结构化摘要(动机/方法/结果/局限),最后用一小节总结它们的关系。使用 Markdown,按【论文标题】分节。`, + "common-themes": `请提炼以上论文的共同主题、共享方法、一致结论与主要分歧。使用 Markdown: +## 共同主题 +## 方法共性 +## 一致结论 +## 主要分歧 +## 综合启示`, +}; + +function buildTopicReviewPrompt(topic: string): string { + return `用户写作/研究主题: +「${topic.trim()}」 + +请整体阅读本会话开头已提供的论文,完成「面向该主题写作」的文献综述与材料整理。使用 Markdown,结构如下: + +## 主题界定 +用 2–4 句说明该主题在所选文献中的边界与核心问题。 + +## 文献地图 +用 Markdown 表格列出:论文 | 与主题的关系(核心/相关/边缘) | 关键贡献 | 可用证据或论点(各 1 句)。 + +## 共识与分歧 +归纳各文在主题上的一致结论、争议点与未解决问题。 + +## 可写入综述的要点 +按「可直接用于写作」的粒度,列出 5–10 条要点;每条注明依据哪几篇(用论文标题)。 + +## 推荐论述结构 +给出一篇短综述/相关工作小节的建议大纲(三级标题即可)。 + +## 写作注意 +指出证据不足、不可过度外推、以及引用时需核对原文的地方。 + +硬性要求: +1. 必须紧扣用户主题,弱相关内容一笔带过或标明边缘。 +2. 用论文标题指称文献;信息不足也要基于已有正文尽力完成,禁止索要全文。 +3. 表格必须是标准 Markdown 表格(含 | --- | 分隔行)。`; +} + +/** Deduplicate to regular items (or standalone PDFs) suitable for chat. */ +export function normalizeLibraryItems(items: Zotero.Item[]): Zotero.Item[] { + const map = new Map(); + for (const item of items) { + if (!item || item.isNote()) continue; + let target = item; + if (item.isAttachment() && item.parentItemID) { + const parent = Zotero.Items.get(item.parentItemID); + if (parent) target = parent; + } + if (target.isRegularItem() || target.isAttachment()) { + map.set(target.id, target); + } + } + return [...map.values()]; +} + +export async function buildMultiChatMessages(options: { + items: Zotero.Item[]; + history: ChatMessage[]; + userText: string; + mode?: MultiChatMode; +}): Promise { + const items = normalizeLibraryItems(options.items); + if (!items.length) { + return { + messages: [], + papers: [], + warning: "未选中可用文献。", + ready: false, + }; + } + + const maxTotal = Number(getPref("maxContextChars") ?? 100000); + const maxPapers = Math.min(items.length, 8); + const selected = items.slice(0, maxPapers); + // Leave headroom for system + history + task on follow-up turns + const historyLen = options.history.reduce( + (n, m) => n + (m.content?.length || 0), + 0, + ); + const reserve = Math.min(24000, Math.max(4000, Math.floor(historyLen * 0.5) + 4000)); + const paperBudget = Math.max(12000, maxTotal - reserve); + const perBudget = Math.max(6000, Math.floor(paperBudget / selected.length)); + + const papers: MultiPaperExtract[] = []; + const warnings: string[] = []; + if (items.length > maxPapers) { + warnings.push(`已限制为前 ${maxPapers} 篇(共选 ${items.length} 篇)。`); + } + + for (const item of selected) { + const extract = await extractPdfContext(item); + const title = + (extract.parentItem || item).getField("title") || + item.attachmentFilename || + `Item ${item.id}`; + + if (!extract.text) { + warnings.push(`《${title}》未能提取 PDF 文本,已跳过。`); + papers.push({ item, title, extract }); + continue; + } + + const sliced = truncateText(extract.text, perBudget); + papers.push({ + item, + title, + extract: { + ...extract, + text: sliced.text, + truncated: extract.truncated || sliced.truncated, + }, + }); + } + + const usable = papers.filter((p) => p.extract.text); + if (!usable.length) { + return { + messages: [], + papers, + warning: warnings.join(" ") || "所选文献均无法提取 PDF 文本。", + ready: false, + }; + } + + warnings.push( + `已载入 ${usable.length} 篇论文(每篇上限约 ${perBudget} 字符)。`, + ); + + const paperBlocks = usable.map((p, idx) => { + const meta = p.extract.parentItem + ? buildMetadataBlock(p.extract.parentItem) + : ""; + return [ + `【论文 ${idx + 1}】${p.title}`, + meta ? `元数据:\n${meta}` : "", + `正文:\n${p.extract.text}`, + ] + .filter(Boolean) + .join("\n"); + }); + + const paperBlock = paperBlocks.join("\n\n----------\n\n"); + + const mode = options.mode || "chat"; + let taskBlock: string; + if (mode === "chat") { + taskBlock = `【任务】 +请基于本会话开头已提供的多篇论文回答用户问题,并结合此前对话继续深入。 +若问题涉及某个写作主题/综述/相关工作,请按「整体阅读 → 提炼可写入要点」来组织答案,并标明各点依据哪几篇。 +对比或并列信息时优先使用标准 Markdown 表格。 +严禁要求用户再次提供全文。 + +【问题】 +${options.userText.trim()}`; + } else if (mode === "topic-review") { + taskBlock = `【任务】 +请直接完成下列「面向写作的主题综述」,不要索要额外文本。可结合此前对话中已讨论的内容继续深化。 + +${buildTopicReviewPrompt(options.userText)}`; + } else { + taskBlock = `【任务】 +请直接完成下列分析,不要索要额外文本。可结合此前对话继续深化。 + +${MODE_PROMPTS[mode]}`; + } + + const system = `${getSystemPrompt()} + +你正在进行多论文整体阅读与主题分析,目标是帮助用户写作相关工作/综述。硬性规则: +1. 论文材料已在本会话首条用户消息中以【论文 N】形式提供;后续每一轮仍以该材料为准。 +2. 禁止说「请提供论文」或「只有片段无法分析」。 +3. 用论文标题区分文献;信息不足也要基于已有正文尽力回答。 +4. 输出使用 Markdown;含对比时使用标准表格(表头下一行必须是 |---|)。 +5. 紧扣用户主题,区分核心证据与边缘相关内容。 +6. 必须结合此前对话上下文持续讨论,不要当成没有论文的新会话。`; + + const messages = assemblePaperChatMessages({ + system, + paperBlock, + taskBlock, + history: options.history, + }); + + return { + messages, + papers, + warning: warnings.join(" "), + ready: true, + }; +} diff --git a/src/modules/pdf/selection.ts b/src/modules/pdf/selection.ts new file mode 100644 index 0000000..c3cd45f --- /dev/null +++ b/src/modules/pdf/selection.ts @@ -0,0 +1,213 @@ +/** + * Read selected text from Zotero PDF/EPUB/Snapshot readers. + * Clicking sidebar buttons often clears DOM selection — prefer + * Reader internal selection popup / selectionRanges, and call + * capture on mousedown with preventDefault. + */ + +function tryString(value: unknown): string { + if (typeof value === "string") return value.trim(); + if (value == null) return ""; + return String(value).trim(); +} + +function textFromSelectionRanges(ranges: any): string { + if (!ranges) return ""; + if (typeof ranges === "string") return ranges.trim(); + if (!Array.isArray(ranges)) return ""; + const parts: string[] = []; + for (const r of ranges) { + if (!r) continue; + const t = + tryString(r.text) || + tryString(r.toString?.()) || + tryString(r.anchorText) || + tryString(r.content); + if (t) parts.push(t); + } + return parts.join("\n").trim(); +} + +function textFromReaderInternals(reader: any): string { + if (!reader) return ""; + + // zotero-plugin-toolkit path: selection popup annotation + try { + const popupText = + reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ?? + reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text; + const t = tryString(popupText); + if (t) return t; + } catch { + // ignore + } + + // Official-ish internal ranges (zotero-dev recommendation) + const views = [ + reader?._internalReader?._primaryView, + reader?._internalReader?._lastView, + reader?._internalReader?._secondaryView, + ]; + + try { + const wrapped = + reader?._iframeWindow?.wrappedJSObject?._reader || + reader?._iframeWindow?.wrappedJSObject?.reader; + if (wrapped) { + views.push(wrapped._primaryView, wrapped._lastView, wrapped._secondaryView); + } + } catch { + // ignore + } + + for (const view of views) { + if (!view) continue; + try { + const fromRanges = textFromSelectionRanges(view._selectionRanges); + if (fromRanges) return fromRanges; + } catch { + // ignore + } + try { + const t = tryString(view._selectionPopup?.annotation?.text); + if (t) return t; + } catch { + // ignore + } + } + + // DOM selection fallbacks inside reader iframe(s) + const windows: Window[] = []; + try { + if (reader._iframeWindow) windows.push(reader._iframeWindow); + } catch { + // ignore + } + try { + const iframe = + reader?._iframe || + reader?._internalReader?._primaryView?._iframe || + reader?._internalReader?._lastView?._iframe; + const cw = iframe?.contentWindow; + if (cw) windows.push(cw); + } catch { + // ignore + } + + for (const win of windows) { + try { + const t = tryString(win.getSelection?.()?.toString?.()); + if (t) return t; + } catch { + // ignore + } + try { + const t = tryString(win.document?.getSelection?.()?.toString?.()); + if (t) return t; + } catch { + // ignore + } + } + + return ""; +} + +function listCandidateReaders(): any[] { + const list: any[] = []; + try { + const tabID = (globalThis as any).Zotero_Tabs?.selectedID; + if (tabID) { + const byTab = Zotero.Reader.getByTabID?.(tabID); + if (byTab) list.push(byTab); + } + } catch { + // ignore + } + try { + const all = + (Zotero.Reader as any).getAll?.() || + (Zotero.Reader as any)._readers || + []; + for (const r of all) { + if (r && !list.includes(r)) list.push(r); + } + } catch { + // ignore + } + return list; +} + +/** Last non-empty selection cached while user selects in the reader. */ +let cachedSelection = ""; +let cachedAt = 0; + +export function cacheReaderSelection(text: string) { + const t = text.trim(); + if (!t) return; + cachedSelection = t; + cachedAt = Date.now(); +} + +export function getCachedReaderSelection(maxAgeMs = 120000): string { + if (!cachedSelection) return ""; + if (Date.now() - cachedAt > maxAgeMs) return ""; + return cachedSelection; +} + +export function getReaderSelectedText(): string { + // 1) Toolkit helper when available + try { + const readers = listCandidateReaders(); + for (const reader of readers) { + try { + const viaToolkit = (ztoolkit as any).Reader?.getSelectedText?.(reader); + const t = tryString(viaToolkit); + if (t) { + cacheReaderSelection(t); + return t; + } + } catch { + // ignore + } + const t = textFromReaderInternals(reader); + if (t) { + cacheReaderSelection(t); + return t; + } + } + } catch (e) { + ztoolkit.log("getReaderSelectedText failed", e); + } + + // 2) Recent cache (survives button focus stealing selection) + const cached = getCachedReaderSelection(); + if (cached) return cached; + + return ""; +} + +/** + * Keep selection cache fresh when user selects text in PDF reader. + * Call once at plugin startup. + */ +export function registerReaderSelectionHook() { + try { + Zotero.Reader.registerEventListener( + "renderTextSelectionPopup", + (event: any) => { + try { + const text = + tryString(event?.params?.annotation?.text) || + tryString(event?.params?.text) || + textFromReaderInternals(event?.reader); + if (text) cacheReaderSelection(text); + } catch (e) { + ztoolkit.log("selection popup hook failed", e); + } + }, + addon.data.config.addonID, + ); + } catch (e) { + ztoolkit.log("registerReaderSelectionHook failed", e); + } +} diff --git a/src/modules/ui/chatView.ts b/src/modules/ui/chatView.ts index 447b893..7c8160b 100644 --- a/src/modules/ui/chatView.ts +++ b/src/modules/ui/chatView.ts @@ -1,10 +1,28 @@ +import { + Bot, + Check, + Copy, + Cpu, + Eraser, + Highlighter, + MessageSquareText, + NotebookPen, + SendHorizontal, + Sparkles, + Square, + User, + type IconNode, +} from "lucide"; import { chatStream, getRuntimeConfig } from "../llm/client"; import { LlmError, type ChatMessage } from "../llm/types"; import { getProviderPreset } from "../llm/providers"; import { buildChatMessages } from "../pdf/context"; +import { getReaderSelectedText } from "../pdf/selection"; import { clearSession, loadSession, saveSession } from "../storage/sessions"; import { createChildNote } from "../zotero/notes"; import { getString } from "../../utils/locale"; +import { markdownToHtml } from "../../utils/markdown"; +import { createLucideIcon } from "../../utils/icons"; import { createAbortHandle, isAbortError, @@ -22,8 +40,11 @@ export class ChatView { private messagesEl!: HTMLElement; private inputEl!: HTMLTextAreaElement; private statusEl!: HTMLElement; + private statusIconEl!: HTMLElement; + private statusTextEl!: HTMLElement; private sendBtn!: HTMLButtonElement; private stopBtn!: HTMLButtonElement; + private emptyEl!: HTMLElement; constructor(doc: Document, body: HTMLElement, item: Zotero.Item) { this.doc = doc; @@ -40,36 +61,78 @@ export class ChatView { const providerLabel = preset?.label || cfg.provider || "未配置"; const header = this.el("div", "chatpapers-header"); - header.append( - this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"), - this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`), + const brand = this.el("div", "chatpapers-brand"); + brand.append( + createLucideIcon(this.doc, MessageSquareText, { + size: 18, + className: "chatpapers-brand-icon", + }), + this.el("div", "chatpapers-brand-text", "ChatPapers"), ); + const titleRow = this.el("div", "chatpapers-title-row"); + titleRow.append( + this.el( + "div", + "chatpapers-title", + this.item.getField("title") || "ChatPapers", + ), + ); + const meta = this.el("div", "chatpapers-meta"); + meta.append( + createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }), + this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`), + ); + header.append(brand, titleRow, meta); this.messagesEl = this.el("div", "chatpapers-messages"); + this.emptyEl = this.el("div", "chatpapers-empty"); + this.emptyEl.append( + createLucideIcon(this.doc, Sparkles, { + size: 28, + className: "chatpapers-empty-icon", + }), + this.el("div", "chatpapers-empty-title", getString("chat-empty-title")), + this.el("div", "chatpapers-empty-desc", getString("chat-empty-desc")), + ); + this.statusEl = this.el("div", "chatpapers-status"); + this.statusIconEl = this.el("span", "chatpapers-status-icon"); + this.statusTextEl = this.el("span", "chatpapers-status-text"); + this.statusEl.append(this.statusIconEl, this.statusTextEl); const toolbar = this.el("div", "chatpapers-toolbar"); - const summarizeBtn = this.btn(getString("chat-summarize"), () => - this.runSummary(), + toolbar.append( + this.iconBtn(Sparkles, getString("chat-summarize"), () => this.runSummary()), + this.iconBtn( + Highlighter, + getString("chat-add-selection"), + () => this.captureSelection(), + undefined, + { preserveSelection: true }, + ), + this.iconBtn(Eraser, getString("chat-clear"), () => this.clearChat()), + this.iconBtn(NotebookPen, getString("chat-save-note"), () => + this.saveLastNote(), + ), ); - const addSelBtn = this.btn(getString("chat-add-selection"), () => - this.captureSelection(), - ); - const clearBtn = this.btn(getString("chat-clear"), () => this.clearChat()); - const saveBtn = this.btn(getString("chat-save-note"), () => this.saveLastNote()); - toolbar.append(summarizeBtn, addSelBtn, clearBtn, saveBtn); + const composer = this.el("div", "chatpapers-composer"); this.inputEl = this.doc.createElement("textarea"); this.inputEl.className = "chatpapers-input"; this.inputEl.rows = 3; this.inputEl.placeholder = getString("chat-placeholder"); const actions = this.el("div", "chatpapers-actions"); - this.sendBtn = this.btn(getString("chat-send"), () => this.send()); - this.sendBtn.classList.add("chatpapers-primary"); - this.stopBtn = this.btn(getString("chat-stop"), () => this.stop()); + this.sendBtn = this.iconBtn( + SendHorizontal, + getString("chat-send"), + () => this.send(), + "chatpapers-primary", + ); + this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => this.stop()); this.stopBtn.disabled = true; actions.append(this.sendBtn, this.stopBtn); + composer.append(this.inputEl, actions); this.inputEl.addEventListener("keydown", (ev) => { const kev = ev as KeyboardEvent; @@ -79,7 +142,13 @@ export class ChatView { } }); - this.body.append(header, this.messagesEl, this.statusEl, toolbar, this.inputEl, actions); + this.body.append( + header, + this.messagesEl, + this.statusEl, + toolbar, + composer, + ); const keys = this.sessionKeys(); this.messages = await loadSession(keys.itemKey, keys.attachmentKey); @@ -95,15 +164,12 @@ export class ChatView { } private sessionKeys(): { itemKey: string; attachmentKey: string } { - const parent = - this.item.isRegularItem() - ? this.item - : this.item.parentItemID - ? Zotero.Items.get(this.item.parentItemID) - : this.item; - const attachment = this.item.isAttachment() + const parent = this.item.isRegularItem() ? this.item - : undefined; + : this.item.parentItemID + ? Zotero.Items.get(this.item.parentItemID) + : this.item; + const attachment = this.item.isAttachment() ? this.item : undefined; return { itemKey: parent.key, attachmentKey: attachment?.key || "", @@ -117,67 +183,154 @@ export class ChatView { return node; } - private btn(label: string, onClick: () => void): HTMLButtonElement { + private iconBtn( + icon: IconNode, + label: string, + onClick: () => void, + extraClass?: string, + options?: { preserveSelection?: boolean }, + ): HTMLButtonElement { const b = this.doc.createElement("button"); b.type = "button"; - b.className = "chatpapers-btn"; - b.textContent = label; - b.addEventListener("click", onClick); + b.className = extraClass + ? `chatpapers-btn ${extraClass}` + : "chatpapers-btn"; + b.title = label; + b.append( + createLucideIcon(this.doc, icon, { size: 15 }), + this.el("span", "chatpapers-btn-label", label), + ); + if (options?.preserveSelection) { + // Prevent focus steal that clears PDF text selection + b.addEventListener("mousedown", (ev) => { + if ((ev as MouseEvent).button !== 0) return; + ev.preventDefault(); + onClick(); + }); + } else { + b.addEventListener("click", onClick); + } return b; } - private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") { - this.statusEl.textContent = text; + private setStatus( + text: string, + kind: "info" | "warn" | "error" | "" = "info", + ) { + this.statusTextEl.textContent = text; this.statusEl.dataset.kind = kind; + this.statusIconEl.replaceChildren(); + if (!text) return; + const icon = + kind === "error" || kind === "warn" + ? Sparkles + : Check; + this.statusIconEl.append( + createLucideIcon(this.doc, icon, { size: 13 }), + ); } private renderMessages() { this.messagesEl.replaceChildren(); - for (const msg of this.messages) { - if (msg.role === "system") continue; + const visible = this.messages.filter((m) => m.role !== "system"); + if (!visible.length) { + this.messagesEl.append(this.emptyEl); + return; + } + + for (const msg of visible) { const bubble = this.el( "div", `chatpapers-bubble chatpapers-${msg.role}`, ); - const role = this.el( - "div", - "chatpapers-role", - msg.role === "user" ? "You" : "AI", + const roleRow = this.el("div", "chatpapers-role"); + const roleLeft = this.el("div", "chatpapers-role-left"); + roleLeft.append( + createLucideIcon(this.doc, msg.role === "user" ? User : Bot, { + size: 13, + className: "chatpapers-role-icon", + }), + this.el("span", undefined, msg.role === "user" ? "You" : "AI"), ); - const content = this.el("div", "chatpapers-content", msg.content); - bubble.append(role, content); + roleRow.append(roleLeft); + if (msg.role === "assistant" && msg.content) { + const copyBtn = this.doc.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "chatpapers-copy"; + copyBtn.title = getString("chat-copy"); + copyBtn.append( + createLucideIcon(this.doc, Copy, { size: 12 }), + this.el("span", undefined, getString("chat-copy")), + ); + copyBtn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + this.copyText(msg.content); + }); + roleRow.append(copyBtn); + } + const content = this.el("div", "chatpapers-content"); + this.fillContent(content, msg.content, msg.role === "assistant"); + bubble.append(roleRow, content); this.messagesEl.append(bubble); } this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } + private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) { + if (asMarkdown) { + el.classList.add("chatpapers-md"); + el.innerHTML = markdownToHtml(text || ""); + } else { + el.classList.remove("chatpapers-md"); + el.textContent = text || ""; + } + } + + private copyText(text: string) { + try { + new ztoolkit.Clipboard().addText(text, "text/unicode").copy(); + this.setStatus(getString("chat-copied"), "info"); + return; + } catch (e) { + ztoolkit.log("Clipboard helper failed", e); + } + try { + (Zotero.Utilities as any).Internal.copyTextToClipboard(text); + this.setStatus(getString("chat-copied"), "info"); + } catch (e) { + ztoolkit.log(e); + this.setStatus(String(e), "error"); + } + } + private appendAssistantPlaceholder(): HTMLElement { const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant"); - bubble.append( - this.el("div", "chatpapers-role", "AI"), - this.el("div", "chatpapers-content", ""), + const roleRow = this.el("div", "chatpapers-role"); + const roleLeft = this.el("div", "chatpapers-role-left"); + roleLeft.append( + createLucideIcon(this.doc, Bot, { size: 13 }), + this.el("span", undefined, "AI"), ); + roleRow.append(roleLeft); + const content = this.el("div", "chatpapers-content chatpapers-md"); + bubble.append(roleRow, content); this.messagesEl.append(bubble); this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - return bubble.querySelector(".chatpapers-content") as HTMLElement; + return content; } private captureSelection() { try { - const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID); - // @ts-expect-error internal reader APIs vary - const win = reader?._iframeWindow || reader?._internalReader?._primaryView?._iframe?.contentWindow; - const sel = win?.getSelection?.()?.toString?.() || ""; - if (!sel.trim()) { - // try main window selection as fallback - const mainSel = this.doc.defaultView?.getSelection?.()?.toString?.() || ""; - this.selectionText = mainSel.trim(); - } else { - this.selectionText = sel.trim(); - } + const text = getReaderSelectedText(); + this.selectionText = text; if (this.selectionText) { + const preview = + this.selectionText.length > 80 + ? `${this.selectionText.slice(0, 80)}…` + : this.selectionText; this.setStatus( - `${getString("chat-selection-added")} (${this.selectionText.length} chars)`, + `${getString("chat-selection-added")} (${this.selectionText.length} chars): ${preview}`, "info", ); } else { @@ -233,32 +386,43 @@ export class ChatView { }); this.selectionText = ""; - if (built.warning) { - this.setStatus(built.warning, "warn"); + if (!built.ready) { + this.setStatus(built.warning || "无法读取 PDF 文本", "error"); + return; } - if (mode === "chat") { - this.messages.push({ role: "user", content: userText }); - } else { - this.messages.push({ role: "user", content: userText }); + if (built.warning) { + this.setStatus(built.warning, "info"); } + + this.messages.push({ role: "user", content: userText }); this.renderMessages(); const contentEl = this.appendAssistantPlaceholder(); let full = ""; + let lastRender = 0; await chatStream({ messages: built.messages, signal: this.abort.signal, onDelta: (delta) => { full += delta; - contentEl.textContent = full; - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + const now = Date.now(); + if (now - lastRender > 80) { + lastRender = now; + this.fillContent(contentEl, full, true); + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } }, }); + this.fillContent(contentEl, full || "(empty)", true); this.messages.push({ role: "assistant", content: full || "(empty)" }); await this.persist(); - this.setStatus(getString("chat-done"), "info"); + const chars = built.extract.text?.length || 0; + this.setStatus( + `${getString("chat-done")} · 已基于 ${chars} 字符论文文本`, + "info", + ); } catch (e) { if (isAbortError(e)) { this.setStatus(getString("chat-stopped"), "warn"); @@ -283,17 +447,18 @@ export class ChatView { } private async saveLastNote() { - const last = [...this.messages].reverse().find((m) => m.role === "assistant"); + const last = [...this.messages] + .reverse() + .find((m) => m.role === "assistant"); if (!last) { this.setStatus(getString("chat-no-reply"), "warn"); return; } - const parent = - this.item.isRegularItem() - ? this.item - : this.item.parentItemID - ? Zotero.Items.get(this.item.parentItemID) - : this.item; + const parent = this.item.isRegularItem() + ? this.item + : this.item.parentItemID + ? Zotero.Items.get(this.item.parentItemID) + : this.item; const title = `ChatPapers · ${new Date().toLocaleString()}`; try { await createChildNote({ diff --git a/src/modules/ui/itemMenu.ts b/src/modules/ui/itemMenu.ts new file mode 100644 index 0000000..02dcd86 --- /dev/null +++ b/src/modules/ui/itemMenu.ts @@ -0,0 +1,51 @@ +import { config } from "../../../package.json"; +import { getString } from "../../utils/locale"; +import { openMultiChatDialog } from "./multiChatDialog"; + +const MENU_CHAT_ID = "chatpapers-itemmenu-chat"; +const MENU_COMPARE_ID = "chatpapers-itemmenu-compare"; + +function getSelectedItems(): Zotero.Item[] { + try { + const pane = Zotero.getActiveZoteroPane?.(); + const items = pane?.getSelectedItems?.() || []; + return items.filter(Boolean); + } catch { + return []; + } +} + +export function registerItemMenus() { + const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`; + + ztoolkit.Menu.register("item", { + tag: "menuitem", + id: MENU_CHAT_ID, + label: getString("itemmenu-chat"), + icon, + commandListener: () => { + const items = getSelectedItems(); + 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); + }, + }); +} diff --git a/src/modules/ui/multiChatDialog.ts b/src/modules/ui/multiChatDialog.ts new file mode 100644 index 0000000..e7b1c91 --- /dev/null +++ b/src/modules/ui/multiChatDialog.ts @@ -0,0 +1,212 @@ +import { config } from "../../../package.json"; +import { getString } from "../../utils/locale"; +import { normalizeLibraryItems } from "../pdf/multiContext"; +import { MultiChatView } from "./multiChatView"; + +let activeDialog: any = null; +let activeView: MultiChatView | null = null; +let onDialogResize: (() => void) | null = null; + +function ensureDialogStyles(doc: Document) { + const id = "chatpapers-styles"; + if (doc.getElementById(id)) return; + const link = doc.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = `chrome://${config.addonRef}/content/chatpapers.css`; + doc.documentElement?.appendChild(link); +} + +/** Prefer a large window that still fits the current screen. */ +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(880, Math.min(Math.round(aw * 0.9), aw - 48)), + height: Math.max(640, Math.min(Math.round(ah * 0.88), ah - 48)), + }; + } catch { + return { width: 1100, height: 780 }; + } +} + +function hideDialogButtonBox(doc: Document) { + const selectors = [ + "#dialog-button-box", + ".dialog-button-box", + "dialog > vbox.dialog-button-box", + "[anonid='buttons']", + ]; + for (const sel of selectors) { + const nodes = Array.from(doc.querySelectorAll(sel)); + for (const node of nodes) { + (node as HTMLElement).style.display = "none"; + } + } +} + +/** Keep chat root filling the window so composer stays pinned to the bottom. */ +function fitMultiChatRoot(win: Window) { + const doc = win.document; + hideDialogButtonBox(doc); + + const root = doc.getElementById( + "chatpapers-multi-root", + ) as HTMLElement | null; + if (!root) return; + + const html = doc.documentElement as HTMLElement | null; + const body = doc.body as HTMLElement | null; + if (html) { + html.style.height = "100%"; + html.style.margin = "0"; + html.style.padding = "0"; + html.style.overflow = "hidden"; + html.style.setProperty("scrollbar-width", "thin"); + } + if (body) { + body.style.height = "100%"; + body.style.margin = "0"; + body.style.padding = "0"; + body.style.overflow = "hidden"; + body.style.display = "flex"; + body.style.flexDirection = "column"; + body.style.setProperty("scrollbar-width", "thin"); + } + + // Shrink leftover dialog grid padding around our cell + for (const node of Array.from(doc.querySelectorAll("hbox, vbox, dialog"))) { + const el = node as HTMLElement; + if ( + el.id === "dialog-button-box" || + el.classList?.contains("dialog-button-box") + ) { + el.style.display = "none"; + continue; + } + if (el.tagName?.toLowerCase() === "dialog") { + el.style.padding = "0"; + el.style.margin = "0"; + el.style.height = "100%"; + el.style.setProperty("scrollbar-width", "thin"); + } + } + + const pad = 4; + const h = Math.max(360, (win.innerHeight || 600) - pad); + 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"; +} + +export function openMultiChatDialog(items: Zotero.Item[]) { + const selected = normalizeLibraryItems(items); + if (!selected.length) { + new ztoolkit.ProgressWindow("ChatPapers") + .createLine({ + text: getString("multi-no-items"), + type: "fail", + }) + .show(); + return; + } + + if (selected.length > 8) { + new ztoolkit.ProgressWindow("ChatPapers") + .createLine({ + text: getString("multi-too-many", { + args: { count: selected.length }, + }), + type: "default", + }) + .show(); + } + + // Close previous dialog if any + try { + if (activeDialog?.window && onDialogResize) { + activeDialog.window.removeEventListener("resize", onDialogResize); + } + activeView?.destroy(); + activeDialog?.window?.close?.(); + } catch { + // ignore + } + activeView = null; + activeDialog = null; + onDialogResize = null; + + const size = getPreferredDialogSize(); + + const dialogHelper = new ztoolkit.Dialog(1, 1) + .addCell(0, 0, { + tag: "div", + namespace: "html", + id: "chatpapers-multi-root", + styles: { + width: "100%", + height: "100%", + minWidth: "640px", + minHeight: "0", + overflow: "hidden", + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + }, + }) + .setDialogData({ + loadCallback: () => { + try { + const win = dialogHelper.window; + const doc = win.document; + ensureDialogStyles(doc); + hideDialogButtonBox(doc); + fitMultiChatRoot(win); + onDialogResize = () => fitMultiChatRoot(win); + win.addEventListener("resize", onDialogResize); + + const root = doc.getElementById( + "chatpapers-multi-root", + ) as HTMLElement | null; + if (!root) return; + activeView = new MultiChatView(doc, root, selected.slice(0, 8)); + void activeView.mount().then(() => fitMultiChatRoot(win)); + } catch (e) { + ztoolkit.log("openMultiChatDialog load failed", e); + } + }, + unloadCallback: () => { + try { + if (activeDialog?.window && onDialogResize) { + activeDialog.window.removeEventListener("resize", onDialogResize); + } + } catch { + // ignore + } + onDialogResize = null; + activeView?.destroy(); + activeView = null; + activeDialog = null; + }, + }); + + activeDialog = dialogHelper; + dialogHelper.open( + `ChatPapers · ${Math.min(selected.length, 8)} papers`, + { + centerscreen: true, + resizable: true, + noDialogMode: true, + fitContent: false, + width: size.width, + height: size.height, + }, + ); +} diff --git a/src/modules/ui/multiChatView.ts b/src/modules/ui/multiChatView.ts new file mode 100644 index 0000000..c665471 --- /dev/null +++ b/src/modules/ui/multiChatView.ts @@ -0,0 +1,423 @@ +import { + Bot, + Copy, + GitCompare, + Layers, + ListTree, + MessageSquareText, + NotebookPen, + SendHorizontal, + Sparkles, + Square, + User, + type IconNode, +} from "lucide"; +import { chatStream, getRuntimeConfig } from "../llm/client"; +import { LlmError, type ChatMessage } from "../llm/types"; +import { getProviderPreset } from "../llm/providers"; +import { + buildMultiChatMessages, + normalizeLibraryItems, + type MultiChatMode, +} from "../pdf/multiContext"; +import { createChildNote } from "../zotero/notes"; +import { getString } from "../../utils/locale"; +import { markdownToHtml } from "../../utils/markdown"; +import { createLucideIcon } from "../../utils/icons"; +import { + createAbortHandle, + isAbortError, + type AbortHandle, +} from "../../utils/abort"; + +export class MultiChatView { + private body: HTMLElement; + private doc: Document; + private items: Zotero.Item[]; + private messages: ChatMessage[] = []; + private abort?: AbortHandle; + + private messagesEl!: HTMLElement; + private inputEl!: HTMLTextAreaElement; + private statusTextEl!: HTMLElement; + private statusEl!: HTMLElement; + private sendBtn!: HTMLButtonElement; + private stopBtn!: HTMLButtonElement; + private emptyEl!: HTMLElement; + + constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) { + this.doc = doc; + this.body = body; + this.items = normalizeLibraryItems(items); + } + + async mount(): Promise { + this.body.replaceChildren(); + this.body.classList.add("chatpapers-root", "chatpapers-multi-root"); + + const cfg = getRuntimeConfig(); + const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined; + const providerLabel = preset?.label || cfg.provider || "未配置"; + + const header = this.el("div", "chatpapers-header"); + const brand = this.el("div", "chatpapers-brand"); + brand.append( + createLucideIcon(this.doc, MessageSquareText, { + size: 18, + className: "chatpapers-brand-icon", + }), + this.el( + "div", + "chatpapers-brand-text", + getString("multi-title"), + ), + ); + + const titles = this.items + .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( + brand, + this.el("div", "chatpapers-title", titles.join(" · ") + more), + this.el( + "div", + "chatpapers-meta", + `${providerLabel} · ${cfg.model || "无模型"}`, + ), + ); + + const paperList = this.el("div", "chatpapers-paper-chips"); + for (const [i, item] of this.items.entries()) { + 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.emptyEl = this.el("div", "chatpapers-empty"); + this.emptyEl.append( + createLucideIcon(this.doc, GitCompare, { + size: 28, + className: "chatpapers-empty-icon", + }), + this.el("div", "chatpapers-empty-title", getString("multi-empty-title")), + this.el("div", "chatpapers-empty-desc", getString("multi-empty-desc")), + ); + + this.statusEl = this.el("div", "chatpapers-status"); + this.statusTextEl = this.el("span", "chatpapers-status-text"); + this.statusEl.append(this.statusTextEl); + + const toolbar = this.el("div", "chatpapers-toolbar"); + toolbar.append( + this.iconBtn(NotebookPen, getString("multi-topic-review"), () => + void this.runTopicReview(), + ), + this.iconBtn(GitCompare, getString("multi-compare"), () => + this.runMode("compare", getString("multi-compare")), + ), + 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.saveLastNote(), + ), + ); + + const composer = this.el("div", "chatpapers-composer"); + this.inputEl = this.doc.createElement("textarea"); + this.inputEl.className = "chatpapers-input"; + this.inputEl.rows = 3; + this.inputEl.placeholder = getString("multi-placeholder"); + + const actions = this.el("div", "chatpapers-actions"); + this.sendBtn = this.iconBtn( + SendHorizontal, + getString("chat-send"), + () => this.send(), + "chatpapers-primary", + ); + this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => + this.stop(), + ); + this.stopBtn.disabled = true; + actions.append(this.sendBtn, this.stopBtn); + composer.append(this.inputEl, actions); + + this.inputEl.addEventListener("keydown", (ev) => { + const kev = ev as KeyboardEvent; + if (kev.key === "Enter" && !kev.shiftKey) { + kev.preventDefault(); + void this.send(); + } + }); + + this.body.append( + header, + paperList, + this.messagesEl, + this.statusEl, + toolbar, + composer, + ); + + this.renderMessages(); + if (!cfg.provider || !cfg.baseUrl) { + this.setStatus(getString("chat-need-config"), "warn"); + } else { + this.setStatus( + getString("multi-ready", { args: { count: this.items.length } }), + "info", + ); + } + } + + destroy(): void { + this.stop(); + this.body.replaceChildren(); + } + + private el(tag: string, className?: string, text?: string): HTMLElement { + const node = this.doc.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; + } + + private iconBtn( + icon: IconNode, + label: string, + onClick: () => void, + extraClass?: string, + ): HTMLButtonElement { + const b = this.doc.createElement("button"); + b.type = "button"; + b.className = extraClass + ? `chatpapers-btn ${extraClass}` + : "chatpapers-btn"; + b.title = label; + b.append( + createLucideIcon(this.doc, icon, { size: 15 }), + this.el("span", "chatpapers-btn-label", label), + ); + b.addEventListener("click", onClick); + return b; + } + + private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") { + this.statusTextEl.textContent = text; + this.statusEl.dataset.kind = kind; + } + + private renderMessages() { + this.messagesEl.replaceChildren(); + const visible = this.messages.filter((m) => m.role !== "system"); + if (!visible.length) { + this.messagesEl.append(this.emptyEl); + return; + } + for (const msg of visible) { + const bubble = this.el( + "div", + `chatpapers-bubble chatpapers-${msg.role}`, + ); + const roleRow = this.el("div", "chatpapers-role"); + const roleLeft = this.el("div", "chatpapers-role-left"); + roleLeft.append( + createLucideIcon(this.doc, msg.role === "user" ? User : Bot, { + size: 13, + }), + this.el("span", undefined, msg.role === "user" ? "You" : "AI"), + ); + roleRow.append(roleLeft); + if (msg.role === "assistant" && msg.content) { + const copyBtn = this.doc.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "chatpapers-copy"; + copyBtn.append( + createLucideIcon(this.doc, Copy, { size: 12 }), + this.el("span", undefined, getString("chat-copy")), + ); + copyBtn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + this.copyText(msg.content); + }); + roleRow.append(copyBtn); + } + const content = this.el("div", "chatpapers-content"); + this.fillContent(content, msg.content, msg.role === "assistant"); + bubble.append(roleRow, content); + this.messagesEl.append(bubble); + } + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + + private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) { + if (asMarkdown) { + el.classList.add("chatpapers-md"); + el.innerHTML = markdownToHtml(text || ""); + } else { + el.classList.remove("chatpapers-md"); + el.textContent = text || ""; + } + } + + private copyText(text: string) { + try { + new ztoolkit.Clipboard().addText(text, "text/unicode").copy(); + this.setStatus(getString("chat-copied"), "info"); + } catch { + try { + (Zotero.Utilities as any).Internal.copyTextToClipboard(text); + this.setStatus(getString("chat-copied"), "info"); + } catch (e) { + this.setStatus(String(e), "error"); + } + } + } + + private appendAssistantPlaceholder(): HTMLElement { + const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant"); + const roleRow = this.el("div", "chatpapers-role"); + const roleLeft = this.el("div", "chatpapers-role-left"); + roleLeft.append( + createLucideIcon(this.doc, Bot, { size: 13 }), + this.el("span", undefined, "AI"), + ); + roleRow.append(roleLeft); + const content = this.el("div", "chatpapers-content chatpapers-md"); + bubble.append(roleRow, content); + this.messagesEl.append(bubble); + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + return content; + } + + private setBusy(busy: boolean) { + this.sendBtn.disabled = busy; + this.stopBtn.disabled = !busy; + this.inputEl.disabled = busy; + } + + private stop() { + this.abort?.abort(); + this.abort = undefined; + this.setBusy(false); + } + + private async send() { + const text = this.inputEl.value.trim(); + if (!text) return; + this.inputEl.value = ""; + await this.run("chat", text); + } + + private async runMode(mode: MultiChatMode, label: string) { + await this.run(mode, label); + } + + /** Topic-oriented lit review for writing; topic comes from the input box. */ + private async runTopicReview() { + const topic = this.inputEl.value.trim(); + if (!topic) { + this.setStatus(getString("multi-need-topic"), "warn"); + this.inputEl.focus(); + return; + } + this.inputEl.value = ""; + await this.run("topic-review", topic); + } + + private async run(mode: MultiChatMode, userText: string) { + this.stop(); + this.abort = createAbortHandle(); + this.setBusy(true); + this.setStatus(getString("chat-thinking"), "info"); + + try { + const built = await buildMultiChatMessages({ + items: this.items, + history: this.messages, + userText, + mode, + }); + + if (!built.ready) { + this.setStatus(built.warning || "无法构建多论文上下文", "error"); + return; + } + if (built.warning) this.setStatus(built.warning, "info"); + + this.messages.push({ role: "user", content: userText }); + this.renderMessages(); + + const contentEl = this.appendAssistantPlaceholder(); + let full = ""; + let lastRender = 0; + await chatStream({ + messages: built.messages, + signal: this.abort.signal, + onDelta: (delta) => { + full += delta; + const now = Date.now(); + if (now - lastRender > 80) { + lastRender = now; + this.fillContent(contentEl, full, true); + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + }, + }); + this.fillContent(contentEl, full || "(empty)", true); + this.messages.push({ role: "assistant", content: full || "(empty)" }); + this.setStatus( + `${getString("chat-done")} · ${built.papers.filter((p) => p.extract.text).length} 篇`, + "info", + ); + } catch (e) { + if (isAbortError(e)) this.setStatus(getString("chat-stopped"), "warn"); + else if (e instanceof LlmError) this.setStatus(e.message, "error"); + else this.setStatus(String(e), "error"); + } finally { + this.setBusy(false); + this.abort = undefined; + this.renderMessages(); + } + } + + private async saveLastNote() { + const last = [...this.messages] + .reverse() + .find((m) => m.role === "assistant"); + if (!last) { + this.setStatus(getString("chat-no-reply"), "warn"); + return; + } + const parent = this.items.find((i) => i.isRegularItem()) || this.items[0]; + if (!parent) return; + const noteParent = parent.isAttachment() && parent.parentItemID + ? Zotero.Items.get(parent.parentItemID) || parent + : parent; + try { + await createChildNote({ + parentItem: noteParent, + title: `ChatPapers 多篇对照 · ${new Date().toLocaleString()}`, + bodyMarkdown: last.content, + }); + this.setStatus(getString("chat-note-saved"), "info"); + new ztoolkit.ProgressWindow("ChatPapers") + .createLine({ text: getString("chat-note-saved"), type: "success" }) + .show(); + } catch (e) { + this.setStatus(String(e), "error"); + } + } +} diff --git a/src/modules/zotero/notes.ts b/src/modules/zotero/notes.ts index d5e3431..be90102 100644 --- a/src/modules/zotero/notes.ts +++ b/src/modules/zotero/notes.ts @@ -1,3 +1,5 @@ +import { markdownToNoteHtml } from "../../utils/markdown"; + export async function createChildNote(options: { parentItem: Zotero.Item; title: string; @@ -7,94 +9,8 @@ export async function createChildNote(options: { note.libraryID = options.parentItem.libraryID; note.parentID = options.parentItem.id; - const html = markdownToSimpleHtml(options.title, options.bodyMarkdown); + const html = markdownToNoteHtml(options.title, options.bodyMarkdown); note.setNote(html); await note.saveTx(); return note; } - -function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** Minimal markdown → HTML for Zotero notes */ -export function markdownToSimpleHtml(title: string, md: string): string { - const lines = md.replace(/\r\n/g, "\n").split("\n"); - const parts: string[] = [ - `

${escapeHtml(title)}

`, - `

Generated by ChatPapers · ${new Date().toLocaleString()}

`, - ]; - - let inList = false; - let inCode = false; - const codeBuf: string[] = []; - - const closeList = () => { - if (inList) { - parts.push(""); - inList = false; - } - }; - - for (const line of lines) { - if (line.startsWith("```")) { - if (inCode) { - parts.push(`
${escapeHtml(codeBuf.join("\n"))}
`); - codeBuf.length = 0; - inCode = false; - } else { - closeList(); - inCode = true; - } - continue; - } - if (inCode) { - codeBuf.push(line); - continue; - } - - const heading = /^(#{1,3})\s+(.*)$/.exec(line); - if (heading) { - closeList(); - const level = heading[1].length; - parts.push(`${escapeHtml(heading[2])}`); - continue; - } - - const li = /^[-*]\s+(.*)$/.exec(line); - if (li) { - if (!inList) { - parts.push("
    "); - inList = true; - } - parts.push(`
  • ${inlineFormat(li[1])}
  • `); - continue; - } - - if (!line.trim()) { - closeList(); - continue; - } - - closeList(); - parts.push(`

    ${inlineFormat(line)}

    `); - } - - closeList(); - if (inCode) { - parts.push(`
    ${escapeHtml(codeBuf.join("\n"))}
    `); - } - - return parts.join("\n"); -} - -function inlineFormat(text: string): string { - let s = escapeHtml(text); - s = s.replace(/\*\*(.+?)\*\*/g, "$1"); - s = s.replace(/`([^`]+)`/g, "$1"); - return s; -} diff --git a/src/utils/icons.ts b/src/utils/icons.ts new file mode 100644 index 0000000..8e3af5a --- /dev/null +++ b/src/utils/icons.ts @@ -0,0 +1,41 @@ +import type { IconNode } from "lucide"; + +export type LucideIcon = IconNode; + +export function createLucideIcon( + doc: Document, + icon: LucideIcon, + options: { + size?: number; + strokeWidth?: number; + className?: string; + } = {}, +): Element { + const size = options.size ?? 16; + const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", String(size)); + svg.setAttribute("height", String(size)); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", String(options.strokeWidth ?? 2)); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("chatpapers-icon"); + if (options.className) { + for (const c of options.className.split(/\s+/)) { + if (c) svg.classList.add(c); + } + } + + for (const node of icon) { + const [tag, attrs] = node; + const el = doc.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [key, value] of Object.entries(attrs)) { + el.setAttribute(key, String(value)); + } + svg.appendChild(el); + } + return svg; +} diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 0000000..172380f --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,340 @@ +/** + * Markdown + LaTeX → HTML for chat bubbles and Zotero notes. + * LaTeX is rendered to MathML via KaTeX (native in Firefox / Zotero). + */ + +import katex from "katex"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderTex(tex: string, displayMode: boolean): string { + const cleaned = tex.trim(); + if (!cleaned) return ""; + try { + const mathml = katex.renderToString(cleaned, { + throwOnError: false, + displayMode, + output: "mathml", + strict: "ignore", + trust: false, + }); + const cls = displayMode + ? "chatpapers-math chatpapers-math-block" + : "chatpapers-math chatpapers-math-inline"; + return `${mathml}`; + } catch { + return `${escapeHtml(cleaned)}`; + } +} + +/** + * Protect code / math segments with placeholders, then restore after MD. + */ +function extractProtected(md: string): { text: string; slots: string[] } { + const slots: string[] = []; + const put = (html: string) => { + const id = slots.length; + slots.push(html); + return `%%CPMATH${id}%%`; + }; + + let text = md; + + // Fenced code blocks first (no math inside) + text = text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_m, _lang, code: string) => { + const body = String(code).replace(/^\n+|\n+$/g, ""); + return put(`
    ${escapeHtml(body)}
    `); + }); + + // Inline code + text = text.replace(/`([^`\n]+)`/g, (_m, code: string) => + put(`${escapeHtml(code)}`), + ); + + // Display: $$...$$ + text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m, tex: string) => + put(renderTex(tex, true)), + ); + + // Display: \[...\] + text = text.replace(/\\\[([\s\S]+?)\\\]/g, (_m, tex: string) => + put(renderTex(tex, true)), + ); + + // Environments: \begin{equation*}...\end{equation*} etc. + text = text.replace( + /\\begin\{(equation\*?|align\*?|gather\*?|multline\*?)\}([\s\S]+?)\\end\{\1\}/g, + (_m, env: string, body: string) => + put(renderTex(`\\begin{${env}}${body}\\end{${env}}`, true)), + ); + + // Inline: \(...\) + text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_m, tex: string) => + put(renderTex(tex, false)), + ); + + // Inline: $...$ (single line, non-empty) + text = text.replace(/\$([^\$\n]+?)\$/g, (_m, tex: string) => { + // Skip currency-like: $5 or $12.99 without latex commands/letters pattern + const t = tex.trim(); + if (/^\d+([.,]\d+)?$/.test(t)) return `$${tex}$`; + return put(renderTex(tex, false)); + }); + + return { text, slots }; +} + +function restoreSlots(html: string, slots: string[]): string { + return html.replace(/%%CPMATH(\d+)%%/g, (_m, id: string) => { + const i = Number(id); + return slots[i] ?? ""; + }); +} + +function inlineFormat(text: string): string { + // Keep placeholders intact while formatting + let s = escapeHtml(text); + // Un-escape placeholders that escapeHtml shouldn't touch anyway (%% is fine) + s = s.replace(/\*\*(.+?)\*\*/g, "$1"); + s = s.replace(/__(.+?)__/g, "$1"); + s = s.replace(/(?$1"); + s = s.replace(/(?$1"); + // inline code already extracted; leftover backticks rare + s = s.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, + '$1', + ); + return s; +} + +function isTableSeparator(line: string): boolean { + const t = line.trim(); + if (!t.includes("|") || !t.includes("-")) return false; + const cells = t + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|"); + return ( + cells.length > 0 && + cells.every((c) => /^\s*:?-{3,}:?\s*$/.test(c)) + ); +} + +function isTableRow(line: string): boolean { + const t = line.trim(); + if (!t.includes("|") || isTableSeparator(t)) return false; + return splitTableCells(t).length >= 2; +} + +function splitTableCells(line: string): string[] { + let t = line.trim(); + if (t.startsWith("|")) t = t.slice(1); + if (t.endsWith("|")) t = t.slice(0, -1); + return t.split("|").map((c) => c.trim()); +} + +function parseAlignments(sepLine: string): Array<"left" | "center" | "right" | ""> { + return splitTableCells(sepLine).map((cell) => { + const left = cell.startsWith(":"); + const right = cell.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return ""; + }); +} + +function renderTableHtml( + header: string[], + aligns: Array<"left" | "center" | "right" | "">, + rows: string[][], +): string { + const alignAttr = (i: number) => { + const a = aligns[i] || ""; + return a ? ` style="text-align:${a}"` : ""; + }; + const ths = header + .map((c, i) => `${inlineFormat(c)}`) + .join(""); + const trs = rows + .map( + (row) => + `${row + .map((c, i) => `${inlineFormat(c)}`) + .join("")}`, + ) + .join(""); + return `
    ${ths}${trs}
    `; +} + +function renderMarkdownLines(normalized: string): string { + const lines = normalized.split("\n"); + const parts: string[] = []; + + let inList: "ul" | "ol" | null = null; + let inBlockquote = false; + let paraBuf: string[] = []; + + const closeList = () => { + if (inList) { + parts.push(``); + inList = null; + } + }; + + const closeQuote = () => { + if (inBlockquote) { + parts.push(""); + inBlockquote = false; + } + }; + + const flushPara = () => { + if (!paraBuf.length) return; + const text = paraBuf.join(" ").trim(); + paraBuf = []; + if (!text) return; + // Standalone math placeholder → no wrapping

    for block math + if (/^%%CPMATH\d+%%$/.test(text) && text.includes("MATH")) { + parts.push(inlineFormat(text)); + return; + } + parts.push(`

    ${inlineFormat(text)}

    `); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // GFM table: header + separator + body rows + if ( + isTableRow(line) && + i + 1 < lines.length && + isTableSeparator(lines[i + 1]) + ) { + flushPara(); + closeList(); + closeQuote(); + const header = splitTableCells(line); + const aligns = parseAlignments(lines[i + 1]); + const rows: string[][] = []; + i += 2; + while (i < lines.length && isTableRow(lines[i])) { + rows.push(splitTableCells(lines[i])); + i++; + } + i--; // compensate for for-loop increment + parts.push(renderTableHtml(header, aligns, rows)); + continue; + } + + // Already-rendered block from slot sitting alone + if (/^\s*%%CPMATH\d+%%\s*$/.test(line)) { + flushPara(); + closeList(); + closeQuote(); + parts.push(line.trim()); + continue; + } + + if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) { + flushPara(); + closeList(); + closeQuote(); + parts.push("
    "); + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + flushPara(); + closeList(); + closeQuote(); + const level = Math.min(heading[1].length, 4); + const title = heading[2].trim(); + if (title) { + parts.push(`${inlineFormat(title)}`); + } + continue; + } + + const bq = /^>\s?(.*)$/.exec(line); + if (bq) { + flushPara(); + closeList(); + const quoteText = bq[1].trim(); + if (!quoteText) continue; + if (!inBlockquote) { + parts.push("
    "); + inBlockquote = true; + } + parts.push(`

    ${inlineFormat(quoteText)}

    `); + continue; + } + closeQuote(); + + const ol = /^(\d+)\.\s+(.*)$/.exec(line); + if (ol) { + flushPara(); + if (inList !== "ol") { + closeList(); + parts.push("
      "); + inList = "ol"; + } + parts.push(`
    1. ${inlineFormat(ol[2].trim())}
    2. `); + continue; + } + + const ul = /^[-*+]\s+(.*)$/.exec(line); + if (ul) { + flushPara(); + if (inList !== "ul") { + closeList(); + parts.push("
        "); + inList = "ul"; + } + parts.push(`
      • ${inlineFormat(ul[1].trim())}
      • `); + continue; + } + + if (!line.trim()) { + flushPara(); + continue; + } + + closeList(); + paraBuf.push(line.trim()); + } + + flushPara(); + closeList(); + closeQuote(); + return parts.join(""); +} + +/** Convert markdown (+ LaTeX) body to safe HTML fragment. */ +export function markdownToHtml(md: string): string { + const normalized = (md || "") + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (!normalized) return ""; + + const { text, slots } = extractProtected(normalized); + const html = renderMarkdownLines(text); + return restoreSlots(html, slots) || `

        ${escapeHtml(md || "")}

        `; +} + +/** Markdown note body with title header for Zotero notes. */ +export function markdownToNoteHtml(title: string, md: string): string { + return [ + `

        ${escapeHtml(title)}

        `, + `

        Generated by ChatPapers · ${new Date().toLocaleString()}

        `, + markdownToHtml(md), + ].join(""); +} diff --git a/typings/i10n.d.ts b/typings/i10n.d.ts index e2ce5d1..453b1cb 100644 --- a/typings/i10n.d.ts +++ b/typings/i10n.d.ts @@ -6,7 +6,11 @@ export type FluentMessageId = | 'chat-add-selection' | 'chat-clear' | 'chat-cleared' + | 'chat-copied' + | 'chat-copy' | 'chat-done' + | 'chat-empty-desc' + | 'chat-empty-title' | 'chat-loading' | 'chat-need-config' | 'chat-no-reply' @@ -22,7 +26,23 @@ export type FluentMessageId = | 'chat-thinking' | 'item-section-chat-head' | 'item-section-chat-sidenav' + | 'itemmenu-chat' + | 'itemmenu-compare' | 'itemmenu-label' + | 'multi-close' + | 'multi-common' + | 'multi-compare' + | 'multi-empty-desc' + | 'multi-empty-title' + | 'multi-need-topic' + | 'multi-need-two' + | 'multi-no-items' + | 'multi-placeholder' + | 'multi-ready' + | 'multi-summary-each' + | 'multi-title' + | 'multi-too-many' + | 'multi-topic-review' | 'prefs-answer-language' | 'prefs-api-key' | 'prefs-base-url'