实现单篇和多篇论文的对比总结

This commit is contained in:
yehongyu
2026-07-20 15:22:40 +08:00
parent 50fd043a2d
commit c46d247665
23 changed files with 2710 additions and 268 deletions
+454 -34
View File
@@ -6,12 +6,33 @@
min-height: 320px; min-height: 320px;
box-sizing: border-box; box-sizing: border-box;
font-size: 13px; font-size: 13px;
-moz-user-select: text;
user-select: text;
} }
.chatpapers-header { .chatpapers-header {
display: flex; display: flex;
flex-direction: column; 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 { .chatpapers-title {
@@ -20,54 +41,92 @@
} }
.chatpapers-meta { .chatpapers-meta {
display: flex;
align-items: center;
gap: 5px;
opacity: 0.7; opacity: 0.7;
font-size: 12px; font-size: 12px;
} }
.chatpapers-messages { .chatpapers-meta-icon {
flex: 1; flex-shrink: 0;
min-height: 180px; opacity: 0.85;
max-height: 420px; }
overflow: auto;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent); .chatpapers-empty {
border-radius: 8px;
padding: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; align-items: center;
background: color-mix(in srgb, currentColor 4%, transparent); justify-content: center;
gap: 6px;
padding: 28px 16px;
text-align: center;
opacity: 0.72;
} }
.chatpapers-bubble { .chatpapers-empty-icon {
padding: 8px 10px; color: #2563eb;
border-radius: 8px; opacity: 0.85;
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;
margin-bottom: 4px; margin-bottom: 4px;
}
.chatpapers-empty-title {
font-weight: 600; 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 { .chatpapers-status {
min-height: 1.2em; min-height: 1.2em;
font-size: 12px; font-size: 12px;
opacity: 0.85; 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"] { .chatpapers-status[data-kind="warn"] {
@@ -78,6 +137,10 @@
color: #b91c1c; color: #b91c1c;
} }
.chatpapers-status[data-kind="info"] .chatpapers-status-icon {
color: #2563eb;
}
.chatpapers-toolbar, .chatpapers-toolbar,
.chatpapers-actions { .chatpapers-actions {
display: flex; display: flex;
@@ -85,14 +148,28 @@
gap: 6px; gap: 6px;
} }
.chatpapers-composer {
display: flex;
flex-direction: column;
gap: 6px;
}
.chatpapers-btn { .chatpapers-btn {
appearance: none; appearance: none;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent); border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, currentColor 6%, transparent); background: color-mix(in srgb, currentColor 6%, transparent);
border-radius: 6px; border-radius: 6px;
padding: 4px 10px; padding: 5px 10px;
cursor: pointer; cursor: pointer;
font: inherit; 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) { .chatpapers-btn:hover:not(:disabled) {
@@ -114,6 +191,261 @@
background: #1d4ed8; 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 { .chatpapers-input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -125,6 +457,8 @@
font: inherit; font: inherit;
background: transparent; background: transparent;
color: inherit; color: inherit;
-moz-user-select: text;
user-select: text;
} }
.chatpapers-loading { .chatpapers-loading {
@@ -138,3 +472,89 @@
margin: 4px 0 10px; margin: 4px 0 10px;
max-width: 52em; 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;
}
+23 -1
View File
@@ -21,4 +21,26 @@ chat-cleared = Chat cleared
chat-no-reply = No AI reply to save yet chat-no-reply = No AI reply to save yet
chat-note-saved = Saved as note chat-note-saved = Saved as note
chat-selection-added = Selection added to next message 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
+2
View File
@@ -1 +1,3 @@
itemmenu-label = ChatPapers: Summarize itemmenu-label = ChatPapers: Summarize
itemmenu-chat = ChatPapers: Open chat
itemmenu-compare = ChatPapers: Compare papers
+24 -1
View File
@@ -21,4 +21,27 @@ chat-cleared = 已清空对话
chat-no-reply = 还没有可保存的 AI 回复 chat-no-reply = 还没有可保存的 AI 回复
chat-note-saved = 已保存为笔记 chat-note-saved = 已保存为笔记
chat-selection-added = 已将选区加入下一条消息 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 } 篇文献,可开始整体阅读与主题综述
+2
View File
@@ -1 +1,3 @@
itemmenu-label = ChatPapers:总结 itemmenu-label = ChatPapers:总结
itemmenu-chat = ChatPapers:打开对话
itemmenu-compare = ChatPapers:多篇对照
+1 -1
View File
@@ -7,7 +7,7 @@ pref("openrouterTitle", "ChatPapers");
pref("temperature", "0.3"); pref("temperature", "0.3");
pref("maxTokens", 2048); pref("maxTokens", 2048);
pref("timeoutMs", 120000); pref("timeoutMs", 120000);
pref("maxContextChars", 80000); pref("maxContextChars", 100000);
pref("answerLanguage", "zh-CN"); pref("answerLanguage", "zh-CN");
pref("systemPrompt", ""); pref("systemPrompt", "");
pref("summaryPrompt", ""); pref("summaryPrompt", "");
+41
View File
@@ -9,9 +9,12 @@
"version": "0.1.0", "version": "0.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"katex": "^0.18.1",
"lucide": "^1.25.0",
"zotero-plugin-toolkit": "^5.1.0-beta.13" "zotero-plugin-toolkit": "^5.1.0-beta.13"
}, },
"devDependencies": { "devDependencies": {
"@types/katex": "^0.16.8",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@zotero-plugin/eslint-config": "^0.6.7", "@zotero-plugin/eslint-config": "^0.6.7",
"eslint": "^9.39.2", "eslint": "^9.39.2",
@@ -1360,6 +1363,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.10.0", "version": "24.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
@@ -2860,6 +2870,31 @@
"setimmediate": "^1.0.5" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2917,6 +2952,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+6 -1
View File
@@ -29,9 +29,12 @@
"update-deps": "npm update --save" "update-deps": "npm update --save"
}, },
"dependencies": { "dependencies": {
"katex": "^0.18.1",
"lucide": "^1.25.0",
"zotero-plugin-toolkit": "^5.1.0-beta.13" "zotero-plugin-toolkit": "^5.1.0-beta.13"
}, },
"devDependencies": { "devDependencies": {
"@types/katex": "^0.16.8",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@zotero-plugin/eslint-config": "^0.6.7", "@zotero-plugin/eslint-config": "^0.6.7",
"eslint": "^9.39.2", "eslint": "^9.39.2",
@@ -46,7 +49,9 @@
"endOfLine": "lf", "endOfLine": "lf",
"overrides": [ "overrides": [
{ {
"files": ["*.xhtml"], "files": [
"*.xhtml"
],
"options": { "options": {
"htmlWhitespaceSensitivity": "css" "htmlWhitespaceSensitivity": "css"
} }
+4
View File
@@ -2,6 +2,8 @@ import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit"; import { createZToolkit } from "./utils/ztoolkit";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane"; import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs"; import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu";
import { registerReaderSelectionHook } from "./modules/pdf/selection";
async function onStartup() { async function onStartup() {
await Promise.all([ await Promise.all([
@@ -13,6 +15,7 @@ async function onStartup() {
initLocale(); initLocale();
registerPrefs(); registerPrefs();
registerChatPane(); registerChatPane();
registerReaderSelectionHook();
await Promise.all( await Promise.all(
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)), Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
@@ -26,6 +29,7 @@ async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
win.MozXULElement.insertFTLIfNeeded( win.MozXULElement.insertFTLIfNeeded(
`${addon.data.config.addonRef}-mainWindow.ftl`, `${addon.data.config.addonRef}-mainWindow.ftl`,
); );
registerItemMenus();
} }
async function onMainWindowUnload(_win: Window): Promise<void> { async function onMainWindowUnload(_win: Window): Promise<void> {
+76
View File
@@ -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;
}
+10 -7
View File
@@ -1,20 +1,23 @@
import { getPref } from "../../utils/prefs"; import { getPref } from "../../utils/prefs";
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题 const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。论文材料会在会话开始时提供,并在整个对话中持续有效
要求: 要求:
1. 回答准确、简洁,优先使用论文原文依据 1. 始终把已提供的【论文正文】/【论文 N】视为可用材料,直接据此回答或总结
2. 若信息不足,请明确说明,不要编造 2. 多轮对话时结合此前问答继续深入,不要假装没有论文或要求用户重新粘贴文本
3. 尽量标注页码或引用短片段(若上下文含页分隔) 3. 回答准确、简洁,优先引用正文依据;不要编造未出现的实验数据
4. 使用用户偏好的语言回答。`; 4. 禁止要求用户「重新提供论文」或声称「只有参考文献/片段」。
5. 使用用户偏好的语言回答。`;
const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含: const DEFAULT_SUMMARY = `请对【论文正文】做结构化总结,使用 Markdown,包含:
## 一句话概括 ## 一句话概括
## 研究问题 / 动机 ## 研究问题 / 动机
## 方法 ## 方法
## 主要结果 ## 主要结果
## 贡献与创新 ## 贡献与创新
## 局限与可追问点 ## 局限与可追问点
## 关键术语(可选)`; ## 关键术语(可选)
注意:直接输出总结内容,不要先解释「文本是否完整」。`;
export function getSystemPrompt(): string { export function getSystemPrompt(): string {
const custom = (getPref("systemPrompt") || "").trim(); const custom = (getPref("systemPrompt") || "").trim();
+65 -31
View File
@@ -1,5 +1,6 @@
import type { ChatMessage } from "../llm/types"; import type { ChatMessage } from "../llm/types";
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts"; import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
import { assemblePaperChatMessages } from "../llm/history";
import { import {
buildMetadataBlock, buildMetadataBlock,
extractPdfContext, extractPdfContext,
@@ -10,6 +11,7 @@ export interface BuiltContext {
messages: ChatMessage[]; messages: ChatMessage[];
extract: ExtractResult; extract: ExtractResult;
warning?: string; warning?: string;
ready: boolean;
} }
export async function buildChatMessages(options: { export async function buildChatMessages(options: {
@@ -23,55 +25,87 @@ export async function buildChatMessages(options: {
const warnings: string[] = []; const warnings: string[] = [];
if (!extract.text) { 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( 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 const meta = extract.parentItem
? buildMetadataBlock(extract.parentItem) ? buildMetadataBlock(extract.parentItem)
: ""; : "";
const systemParts = [getSystemPrompt()]; const paperBlock = [
if (meta) { meta ? `【论文元数据】\n${meta}` : "",
systemParts.push(`论文元数据:\n${meta}`); `论文正文】\n${extract.text}`,
} ]
if (extract.text) { .filter(Boolean)
systemParts.push( .join("\n\n");
`论文正文(页之间可能以换页符分隔):\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);
let taskBlock: string;
if (options.mode === "summary") { if (options.mode === "summary") {
const summaryAsk = getSummaryPrompt(); taskBlock = `【任务】
messages.push({ 请直接基于本会话开头已提供的【论文正文】完成结构化总结。
role: "user", 严禁回复「请提供论文」「文本不完整无法总结」等推脱话术;信息不足时也请基于已有正文尽力总结,并简要注明局限。
content: summaryAsk,
}); ${getSummaryPrompt()}`;
} else { } else {
let content = options.userText.trim(); let question = options.userText.trim();
if (options.selection?.trim()) { if (options.selection?.trim()) {
content = question = `【选中原文】\n${options.selection.trim()}\n\n【问题】\n${question}`;
`【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`; } 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 { return {
messages, messages,
extract, extract,
warning: warnings.length ? warnings.join(" ") : undefined, warning: warnings.length ? warnings.join(" ") : undefined,
ready: true,
}; };
} }
+230 -36
View File
@@ -4,29 +4,55 @@ export interface ExtractResult {
text: string; text: string;
truncated: boolean; truncated: boolean;
pageCount?: number; pageCount?: number;
source: "pdfworker" | "fulltext" | "empty"; source: "pdfworker" | "attachmentText" | "reader" | "empty";
attachment?: Zotero.Item; attachment?: Zotero.Item;
parentItem?: 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 { function resolvePDFWorker(): any {
if (item.isAttachment() && item.attachmentContentType === "application/pdf") { const candidates = [
return item; (Zotero as any).PDFWorker,
(globalThis as any).Zotero?.PDFWorker,
];
try {
candidates.push(ztoolkit.getGlobal("Zotero" as any)?.PDFWorker);
} catch {
// ignore
} }
if (item.isRegularItem()) { try {
const attachments = item.getAttachments().map((id) => Zotero.Items.get(id)); const win = Zotero.getMainWindow?.() as any;
return attachments.find( if (win?.Zotero?.PDFWorker) candidates.push(win.Zotero.PDFWorker);
(att) => att?.isAttachment() && att.attachmentContentType === "application/pdf", } catch {
); // ignore
} }
// Child attachment of a parent for (const worker of candidates) {
if (item.isAttachment() && item.parentItemID) { if (worker?.getFullText) return worker;
const parent = Zotero.Items.get(item.parentItemID);
return findPdfAttachment(parent);
} }
return undefined; 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 { function getParentItem(item: Zotero.Item): Zotero.Item {
if (item.isRegularItem()) return item; if (item.isRegularItem()) return item;
if (item.parentItemID) { if (item.parentItemID) {
@@ -35,25 +61,127 @@ function getParentItem(item: Zotero.Item): Zotero.Item {
return 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<string> { async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
const worker = (Zotero as any).PDFWorker; const worker = resolvePDFWorker();
if (!worker?.getFullText) { if (!worker?.getFullText) {
throw new Error("PDFWorker unavailable"); 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<string> { const attempts: Array<() => Promise<unknown>> = [
try { () => worker.getFullText(attachment.id),
const text = (attachment as any).attachmentText; () => worker.getFullText(attachment.id, null, true),
if (typeof text === "string" && text.trim()) return text.trim(); () => worker.getFullText(attachment.id, true),
} catch { ];
// ignore
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 ""; return "";
} }
async function extractViaAttachmentText(attachment: Zotero.Item): Promise<string> {
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( export function truncateText(
text: string, text: string,
maxChars: number, maxChars: number,
@@ -61,13 +189,54 @@ export function truncateText(
if (text.length <= maxChars) { if (text.length <= maxChars) {
return { text, truncated: false }; return { text, truncated: false };
} }
const head = Math.floor(maxChars * 0.7);
const tail = maxChars - head - 80; const { body, removedRefs } = stripReferencesSection(text);
const sliced = const source = body.length > 1000 ? body : text;
text.slice(0, head) +
"\n\n[... 中间内容已截断 ...]\n\n" + if (source.length <= maxChars) {
text.slice(-Math.max(tail, 0)); const note = removedRefs
return { text: sliced, truncated: true }; ? "\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<ExtractResult> { export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
@@ -79,30 +248,52 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
truncated: false, truncated: false,
source: "empty", source: "empty",
parentItem, parentItem,
error: "当前条目下未找到 PDF 附件。",
}; };
} }
const errors: string[] = [];
let raw = ""; let raw = "";
let source: ExtractResult["source"] = "empty"; let source: ExtractResult["source"] = "empty";
try { try {
raw = await extractViaPdfWorker(attachment); raw = await extractViaPdfWorker(attachment);
if (raw) source = "pdfworker"; if (raw) source = "pdfworker";
} catch (e) { } catch (e) {
errors.push(`PDFWorker: ${String(e)}`);
ztoolkit.log("PDFWorker extract failed", e); ztoolkit.log("PDFWorker extract failed", e);
} }
if (!raw) { if (!raw) {
try { try {
raw = await extractViaFulltext(attachment); raw = await extractViaAttachmentText(attachment);
if (raw) source = "fulltext"; if (raw) source = "attachmentText";
} catch (e) { } catch (e) {
ztoolkit.log("Fulltext extract failed", e); errors.push(`attachmentText: ${String(e)}`);
ztoolkit.log("attachmentText extract failed", e);
} }
} }
const maxChars = Number(getPref("maxContextChars") ?? 80000); if (!raw) {
return {
text: "",
truncated: false,
source: "empty",
attachment,
parentItem,
error:
errors.join(" | ") ||
"未能提取 PDF 文本(可能是扫描版/无文本层,或附件尚未索引)。",
};
}
const rawLength = raw.length;
const { body } = stripReferencesSection(raw);
const bodyLength = body.length;
const maxChars = Number(getPref("maxContextChars") ?? 100000);
const { text, truncated } = truncateText(raw, maxChars); const { text, truncated } = truncateText(raw, maxChars);
const pageCount = raw ? raw.split("\f").length : undefined; const pageCount = raw.includes("\f") ? raw.split("\f").length : undefined;
return { return {
text, text,
@@ -111,13 +302,16 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
source, source,
attachment, attachment,
parentItem, parentItem,
rawLength,
bodyLength,
}; };
} }
export function buildMetadataBlock(item: Zotero.Item): string { export function buildMetadataBlock(item: Zotero.Item): string {
if (!getPref("sendMetadata")) return ""; if (!getPref("sendMetadata")) return "";
const title = item.getField("title") || ""; const title = item.getField("title") || "";
const creators = item.getCreators() const creators = item
.getCreators()
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" ")) .map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
+235
View File
@@ -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<MultiChatMode, "chat" | "topic-review">,
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<number, Zotero.Item>();
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<MultiBuiltContext> {
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,
};
}
+213
View File
@@ -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);
}
}
+234 -69
View File
@@ -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 { chatStream, getRuntimeConfig } from "../llm/client";
import { LlmError, type ChatMessage } from "../llm/types"; import { LlmError, type ChatMessage } from "../llm/types";
import { getProviderPreset } from "../llm/providers"; import { getProviderPreset } from "../llm/providers";
import { buildChatMessages } from "../pdf/context"; import { buildChatMessages } from "../pdf/context";
import { getReaderSelectedText } from "../pdf/selection";
import { clearSession, loadSession, saveSession } from "../storage/sessions"; import { clearSession, loadSession, saveSession } from "../storage/sessions";
import { createChildNote } from "../zotero/notes"; import { createChildNote } from "../zotero/notes";
import { getString } from "../../utils/locale"; import { getString } from "../../utils/locale";
import { markdownToHtml } from "../../utils/markdown";
import { createLucideIcon } from "../../utils/icons";
import { import {
createAbortHandle, createAbortHandle,
isAbortError, isAbortError,
@@ -22,8 +40,11 @@ export class ChatView {
private messagesEl!: HTMLElement; private messagesEl!: HTMLElement;
private inputEl!: HTMLTextAreaElement; private inputEl!: HTMLTextAreaElement;
private statusEl!: HTMLElement; private statusEl!: HTMLElement;
private statusIconEl!: HTMLElement;
private statusTextEl!: HTMLElement;
private sendBtn!: HTMLButtonElement; private sendBtn!: HTMLButtonElement;
private stopBtn!: HTMLButtonElement; private stopBtn!: HTMLButtonElement;
private emptyEl!: HTMLElement;
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) { constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
this.doc = doc; this.doc = doc;
@@ -40,36 +61,78 @@ export class ChatView {
const providerLabel = preset?.label || cfg.provider || "未配置"; const providerLabel = preset?.label || cfg.provider || "未配置";
const header = this.el("div", "chatpapers-header"); const header = this.el("div", "chatpapers-header");
header.append( const brand = this.el("div", "chatpapers-brand");
this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"), brand.append(
this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`), 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.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.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 toolbar = this.el("div", "chatpapers-toolbar");
const summarizeBtn = this.btn(getString("chat-summarize"), () => toolbar.append(
this.runSummary(), 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 = this.doc.createElement("textarea");
this.inputEl.className = "chatpapers-input"; this.inputEl.className = "chatpapers-input";
this.inputEl.rows = 3; this.inputEl.rows = 3;
this.inputEl.placeholder = getString("chat-placeholder"); this.inputEl.placeholder = getString("chat-placeholder");
const actions = this.el("div", "chatpapers-actions"); const actions = this.el("div", "chatpapers-actions");
this.sendBtn = this.btn(getString("chat-send"), () => this.send()); this.sendBtn = this.iconBtn(
this.sendBtn.classList.add("chatpapers-primary"); SendHorizontal,
this.stopBtn = this.btn(getString("chat-stop"), () => this.stop()); getString("chat-send"),
() => this.send(),
"chatpapers-primary",
);
this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => this.stop());
this.stopBtn.disabled = true; this.stopBtn.disabled = true;
actions.append(this.sendBtn, this.stopBtn); actions.append(this.sendBtn, this.stopBtn);
composer.append(this.inputEl, actions);
this.inputEl.addEventListener("keydown", (ev) => { this.inputEl.addEventListener("keydown", (ev) => {
const kev = ev as KeyboardEvent; 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(); const keys = this.sessionKeys();
this.messages = await loadSession(keys.itemKey, keys.attachmentKey); this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
@@ -95,15 +164,12 @@ export class ChatView {
} }
private sessionKeys(): { itemKey: string; attachmentKey: string } { private sessionKeys(): { itemKey: string; attachmentKey: string } {
const parent = const parent = this.item.isRegularItem()
this.item.isRegularItem()
? this.item
: this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const attachment = this.item.isAttachment()
? this.item ? this.item
: undefined; : this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const attachment = this.item.isAttachment() ? this.item : undefined;
return { return {
itemKey: parent.key, itemKey: parent.key,
attachmentKey: attachment?.key || "", attachmentKey: attachment?.key || "",
@@ -117,67 +183,154 @@ export class ChatView {
return node; 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"); const b = this.doc.createElement("button");
b.type = "button"; b.type = "button";
b.className = "chatpapers-btn"; b.className = extraClass
b.textContent = label; ? `chatpapers-btn ${extraClass}`
b.addEventListener("click", onClick); : "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; return b;
} }
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") { private setStatus(
this.statusEl.textContent = text; text: string,
kind: "info" | "warn" | "error" | "" = "info",
) {
this.statusTextEl.textContent = text;
this.statusEl.dataset.kind = kind; 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() { private renderMessages() {
this.messagesEl.replaceChildren(); this.messagesEl.replaceChildren();
for (const msg of this.messages) { const visible = this.messages.filter((m) => m.role !== "system");
if (msg.role === "system") continue; if (!visible.length) {
this.messagesEl.append(this.emptyEl);
return;
}
for (const msg of visible) {
const bubble = this.el( const bubble = this.el(
"div", "div",
`chatpapers-bubble chatpapers-${msg.role}`, `chatpapers-bubble chatpapers-${msg.role}`,
); );
const role = this.el( const roleRow = this.el("div", "chatpapers-role");
"div", const roleLeft = this.el("div", "chatpapers-role-left");
"chatpapers-role", roleLeft.append(
msg.role === "user" ? "You" : "AI", 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); roleRow.append(roleLeft);
bubble.append(role, content); 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.append(bubble);
} }
this.messagesEl.scrollTop = this.messagesEl.scrollHeight; 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 { private appendAssistantPlaceholder(): HTMLElement {
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant"); const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
bubble.append( const roleRow = this.el("div", "chatpapers-role");
this.el("div", "chatpapers-role", "AI"), const roleLeft = this.el("div", "chatpapers-role-left");
this.el("div", "chatpapers-content", ""), 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.append(bubble);
this.messagesEl.scrollTop = this.messagesEl.scrollHeight; this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
return bubble.querySelector(".chatpapers-content") as HTMLElement; return content;
} }
private captureSelection() { private captureSelection() {
try { try {
const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID); const text = getReaderSelectedText();
// @ts-expect-error internal reader APIs vary this.selectionText = text;
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();
}
if (this.selectionText) { if (this.selectionText) {
const preview =
this.selectionText.length > 80
? `${this.selectionText.slice(0, 80)}`
: this.selectionText;
this.setStatus( this.setStatus(
`${getString("chat-selection-added")} (${this.selectionText.length} chars)`, `${getString("chat-selection-added")} (${this.selectionText.length} chars): ${preview}`,
"info", "info",
); );
} else { } else {
@@ -233,32 +386,43 @@ export class ChatView {
}); });
this.selectionText = ""; this.selectionText = "";
if (built.warning) { if (!built.ready) {
this.setStatus(built.warning, "warn"); this.setStatus(built.warning || "无法读取 PDF 文本", "error");
return;
} }
if (mode === "chat") { if (built.warning) {
this.messages.push({ role: "user", content: userText }); this.setStatus(built.warning, "info");
} else {
this.messages.push({ role: "user", content: userText });
} }
this.messages.push({ role: "user", content: userText });
this.renderMessages(); this.renderMessages();
const contentEl = this.appendAssistantPlaceholder(); const contentEl = this.appendAssistantPlaceholder();
let full = ""; let full = "";
let lastRender = 0;
await chatStream({ await chatStream({
messages: built.messages, messages: built.messages,
signal: this.abort.signal, signal: this.abort.signal,
onDelta: (delta) => { onDelta: (delta) => {
full += delta; full += delta;
contentEl.textContent = full; const now = Date.now();
this.messagesEl.scrollTop = this.messagesEl.scrollHeight; 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.messages.push({ role: "assistant", content: full || "(empty)" });
await this.persist(); 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) { } catch (e) {
if (isAbortError(e)) { if (isAbortError(e)) {
this.setStatus(getString("chat-stopped"), "warn"); this.setStatus(getString("chat-stopped"), "warn");
@@ -283,17 +447,18 @@ export class ChatView {
} }
private async saveLastNote() { 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) { if (!last) {
this.setStatus(getString("chat-no-reply"), "warn"); this.setStatus(getString("chat-no-reply"), "warn");
return; return;
} }
const parent = const parent = this.item.isRegularItem()
this.item.isRegularItem() ? this.item
? this.item : this.item.parentItemID
: this.item.parentItemID ? Zotero.Items.get(this.item.parentItemID)
? Zotero.Items.get(this.item.parentItemID) : this.item;
: this.item;
const title = `ChatPapers · ${new Date().toLocaleString()}`; const title = `ChatPapers · ${new Date().toLocaleString()}`;
try { try {
await createChildNote({ await createChildNote({
+51
View File
@@ -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);
},
});
}
+212
View File
@@ -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,
},
);
}
+423
View File
@@ -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<void> {
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");
}
}
}
+3 -87
View File
@@ -1,3 +1,5 @@
import { markdownToNoteHtml } from "../../utils/markdown";
export async function createChildNote(options: { export async function createChildNote(options: {
parentItem: Zotero.Item; parentItem: Zotero.Item;
title: string; title: string;
@@ -7,94 +9,8 @@ export async function createChildNote(options: {
note.libraryID = options.parentItem.libraryID; note.libraryID = options.parentItem.libraryID;
note.parentID = options.parentItem.id; note.parentID = options.parentItem.id;
const html = markdownToSimpleHtml(options.title, options.bodyMarkdown); const html = markdownToNoteHtml(options.title, options.bodyMarkdown);
note.setNote(html); note.setNote(html);
await note.saveTx(); await note.saveTx();
return note; return note;
} }
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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[] = [
`<h1>${escapeHtml(title)}</h1>`,
`<p><i>Generated by ChatPapers · ${new Date().toLocaleString()}</i></p>`,
];
let inList = false;
let inCode = false;
const codeBuf: string[] = [];
const closeList = () => {
if (inList) {
parts.push("</ul>");
inList = false;
}
};
for (const line of lines) {
if (line.startsWith("```")) {
if (inCode) {
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
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(`<h${level + 1}>${escapeHtml(heading[2])}</h${level + 1}>`);
continue;
}
const li = /^[-*]\s+(.*)$/.exec(line);
if (li) {
if (!inList) {
parts.push("<ul>");
inList = true;
}
parts.push(`<li>${inlineFormat(li[1])}</li>`);
continue;
}
if (!line.trim()) {
closeList();
continue;
}
closeList();
parts.push(`<p>${inlineFormat(line)}</p>`);
}
closeList();
if (inCode) {
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
}
return parts.join("\n");
}
function inlineFormat(text: string): string {
let s = escapeHtml(text);
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
return s;
}
+41
View File
@@ -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;
}
+340
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `<span class="${cls}">${mathml}</span>`;
} catch {
return `<code class="chatpapers-tex">${escapeHtml(cleaned)}</code>`;
}
}
/**
* 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(`<pre><code>${escapeHtml(body)}</code></pre>`);
});
// Inline code
text = text.replace(/`([^`\n]+)`/g, (_m, code: string) =>
put(`<code>${escapeHtml(code)}</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, "<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>");
// inline code already extracted; leftover backticks rare
s = s.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" title="$2">$1</a>',
);
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) => `<th${alignAttr(i)}>${inlineFormat(c)}</th>`)
.join("");
const trs = rows
.map(
(row) =>
`<tr>${row
.map((c, i) => `<td${alignAttr(i)}>${inlineFormat(c)}</td>`)
.join("")}</tr>`,
)
.join("");
return `<div class="chatpapers-table-wrap"><table class="chatpapers-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
}
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}>`);
inList = null;
}
};
const closeQuote = () => {
if (inBlockquote) {
parts.push("</blockquote>");
inBlockquote = false;
}
};
const flushPara = () => {
if (!paraBuf.length) return;
const text = paraBuf.join(" ").trim();
paraBuf = [];
if (!text) return;
// Standalone math placeholder → no wrapping <p> for block math
if (/^%%CPMATH\d+%%$/.test(text) && text.includes("MATH")) {
parts.push(inlineFormat(text));
return;
}
parts.push(`<p>${inlineFormat(text)}</p>`);
};
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("<hr/>");
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(`<h${level}>${inlineFormat(title)}</h${level}>`);
}
continue;
}
const bq = /^>\s?(.*)$/.exec(line);
if (bq) {
flushPara();
closeList();
const quoteText = bq[1].trim();
if (!quoteText) continue;
if (!inBlockquote) {
parts.push("<blockquote>");
inBlockquote = true;
}
parts.push(`<p>${inlineFormat(quoteText)}</p>`);
continue;
}
closeQuote();
const ol = /^(\d+)\.\s+(.*)$/.exec(line);
if (ol) {
flushPara();
if (inList !== "ol") {
closeList();
parts.push("<ol>");
inList = "ol";
}
parts.push(`<li>${inlineFormat(ol[2].trim())}</li>`);
continue;
}
const ul = /^[-*+]\s+(.*)$/.exec(line);
if (ul) {
flushPara();
if (inList !== "ul") {
closeList();
parts.push("<ul>");
inList = "ul";
}
parts.push(`<li>${inlineFormat(ul[1].trim())}</li>`);
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) || `<p>${escapeHtml(md || "")}</p>`;
}
/** Markdown note body with title header for Zotero notes. */
export function markdownToNoteHtml(title: string, md: string): string {
return [
`<h1>${escapeHtml(title)}</h1>`,
`<p><i>Generated by ChatPapers · ${new Date().toLocaleString()}</i></p>`,
markdownToHtml(md),
].join("");
}
+20
View File
@@ -6,7 +6,11 @@ export type FluentMessageId =
| 'chat-add-selection' | 'chat-add-selection'
| 'chat-clear' | 'chat-clear'
| 'chat-cleared' | 'chat-cleared'
| 'chat-copied'
| 'chat-copy'
| 'chat-done' | 'chat-done'
| 'chat-empty-desc'
| 'chat-empty-title'
| 'chat-loading' | 'chat-loading'
| 'chat-need-config' | 'chat-need-config'
| 'chat-no-reply' | 'chat-no-reply'
@@ -22,7 +26,23 @@ export type FluentMessageId =
| 'chat-thinking' | 'chat-thinking'
| 'item-section-chat-head' | 'item-section-chat-head'
| 'item-section-chat-sidenav' | 'item-section-chat-sidenav'
| 'itemmenu-chat'
| 'itemmenu-compare'
| 'itemmenu-label' | '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-answer-language'
| 'prefs-api-key' | 'prefs-api-key'
| 'prefs-base-url' | 'prefs-base-url'