实现单篇和多篇论文的对比总结
This commit is contained in:
+454
-34
@@ -6,12 +6,33 @@
|
||||
min-height: 320px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.chatpapers-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chatpapers-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chatpapers-brand-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-title {
|
||||
@@ -20,54 +41,92 @@
|
||||
}
|
||||
|
||||
.chatpapers-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chatpapers-messages {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.chatpapers-meta-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chatpapers-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.chatpapers-bubble {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chatpapers-user {
|
||||
background: color-mix(in srgb, #3b82f6 18%, transparent);
|
||||
align-self: flex-end;
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
.chatpapers-assistant {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
align-self: flex-start;
|
||||
max-width: 96%;
|
||||
}
|
||||
|
||||
.chatpapers-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
.chatpapers-empty-icon {
|
||||
color: #2563eb;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chatpapers-empty-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chatpapers-empty-desc {
|
||||
font-size: 12px;
|
||||
max-width: 220px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatpapers-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-role-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chatpapers-role-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chatpapers-copy {
|
||||
appearance: none;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chatpapers-copy:hover {
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-status {
|
||||
min-height: 1.2em;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chatpapers-status-icon {
|
||||
display: inline-flex;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-status[data-kind="warn"] {
|
||||
@@ -78,6 +137,10 @@
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.chatpapers-status[data-kind="info"] .chatpapers-status-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.chatpapers-toolbar,
|
||||
.chatpapers-actions {
|
||||
display: flex;
|
||||
@@ -85,14 +148,28 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-btn {
|
||||
appearance: none;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chatpapers-btn-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chatpapers-btn:hover:not(:disabled) {
|
||||
@@ -114,6 +191,261 @@
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.chatpapers-messages {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
-moz-user-select: text !important;
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.chatpapers-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-messages::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, currentColor 32%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chatpapers-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chatpapers-bubble {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
-moz-user-select: text !important;
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.chatpapers-bubble .chatpapers-table-wrap {
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.chatpapers-content {
|
||||
-moz-user-select: text !important;
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chatpapers-content.chatpapers-md {
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatpapers-md > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.chatpapers-md > :last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.chatpapers-md h1,
|
||||
.chatpapers-md h2,
|
||||
.chatpapers-md h3,
|
||||
.chatpapers-md h4 {
|
||||
margin: 0.45em 0 0.15em;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chatpapers-md h1 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.chatpapers-md h2 {
|
||||
font-size: 1.08em;
|
||||
}
|
||||
|
||||
.chatpapers-md h3,
|
||||
.chatpapers-md h4 {
|
||||
font-size: 1.02em;
|
||||
}
|
||||
|
||||
.chatpapers-md p {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
.chatpapers-md ul,
|
||||
.chatpapers-md ol {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
|
||||
.chatpapers-md li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chatpapers-md li + li {
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
|
||||
.chatpapers-md code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-md pre {
|
||||
margin: 0.35em 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-md pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.chatpapers-md blockquote {
|
||||
margin: 0.3em 0;
|
||||
padding: 0 0.65em;
|
||||
border-left: 3px solid color-mix(in srgb, currentColor 35%, transparent);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.chatpapers-md blockquote p {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.chatpapers-md hr {
|
||||
border: none;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.chatpapers-md a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chatpapers-md strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chatpapers-md em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chatpapers-table-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 0.45em 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chatpapers-md table.chatpapers-table,
|
||||
.chatpapers-md .chatpapers-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
max-width: none;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
font-size: 0.92em;
|
||||
line-height: 1.35;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.chatpapers-md .chatpapers-table th,
|
||||
.chatpapers-md .chatpapers-table td {
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
padding: 0.35em 0.55em;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chatpapers-md .chatpapers-table th {
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chatpapers-md .chatpapers-table tbody tr:nth-child(even) td {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-math-inline {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chatpapers-math-block {
|
||||
display: block;
|
||||
margin: 0.35em 0;
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chatpapers-math math {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.chatpapers-math-block math {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.chatpapers-tex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-user {
|
||||
background: color-mix(in srgb, #3b82f6 18%, transparent);
|
||||
align-self: flex-end;
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
.chatpapers-assistant {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
align-self: flex-start;
|
||||
max-width: 96%;
|
||||
}
|
||||
|
||||
.chatpapers-role {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chatpapers-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -125,6 +457,8 @@
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.chatpapers-loading {
|
||||
@@ -138,3 +472,89 @@
|
||||
margin: 4px 0 10px;
|
||||
max-width: 52em;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-header,
|
||||
.chatpapers-multi-root .chatpapers-paper-chips,
|
||||
.chatpapers-multi-root .chatpapers-status,
|
||||
.chatpapers-multi-root .chatpapers-toolbar,
|
||||
.chatpapers-multi-root .chatpapers-composer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 140px;
|
||||
max-height: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-empty-desc {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-composer {
|
||||
margin-top: 2px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-toolbar {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Dialog chrome: hide empty footer button area; thin scrollbars */
|
||||
#dialog-button-box,
|
||||
.dialog-button-box {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chatpapers-table-wrap,
|
||||
.chatpapers-multi-root {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.chatpapers-table-wrap::-webkit-scrollbar,
|
||||
.chatpapers-multi-root::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-table-wrap::-webkit-scrollbar-thumb,
|
||||
.chatpapers-multi-root::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, currentColor 32%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chatpapers-paper-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 72px;
|
||||
overflow: auto;
|
||||
margin-bottom: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chatpapers-chip {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
background: color-mix(in srgb, #2563eb 10%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,26 @@ chat-cleared = Chat cleared
|
||||
chat-no-reply = No AI reply to save yet
|
||||
chat-note-saved = Saved as note
|
||||
chat-selection-added = Selection added to next message
|
||||
chat-selection-empty = No text selected in the reader
|
||||
chat-selection-empty = No text selected. Select text in the PDF first, then click this button (do not click elsewhere in between).
|
||||
chat-copy = Copy
|
||||
chat-copied = Copied to clipboard
|
||||
chat-empty-title = Start a conversation
|
||||
chat-empty-desc = Summarize this paper or ask a question about methods, results, and limitations.
|
||||
|
||||
itemmenu-chat = ChatPapers: Open chat
|
||||
itemmenu-compare = ChatPapers: Compare papers
|
||||
|
||||
multi-title = Multi-paper chat
|
||||
multi-empty-title = Read papers together
|
||||
multi-empty-desc = Enter a research topic and click Topic review for writing-ready notes; or compare, summarize each, or ask freely.
|
||||
multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation
|
||||
multi-topic-review = Topic review
|
||||
multi-need-topic = Enter a research topic in the box, then click Topic review
|
||||
multi-compare = Compare
|
||||
multi-summary-each = Summarize each
|
||||
multi-common = Common themes
|
||||
multi-close = Close
|
||||
multi-no-items = Please select items first
|
||||
multi-need-two = Select at least 2 items to compare
|
||||
multi-too-many = { $count } items selected; using the first 8
|
||||
multi-ready = { $count } papers ready for topic review
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
itemmenu-label = ChatPapers: Summarize
|
||||
itemmenu-chat = ChatPapers: Open chat
|
||||
itemmenu-compare = ChatPapers: Compare papers
|
||||
|
||||
@@ -21,4 +21,27 @@ chat-cleared = 已清空对话
|
||||
chat-no-reply = 还没有可保存的 AI 回复
|
||||
chat-note-saved = 已保存为笔记
|
||||
chat-selection-added = 已将选区加入下一条消息
|
||||
chat-selection-empty = 阅读器中没有选中文本
|
||||
chat-selection-empty = 未检测到选区。请先在 PDF 中拖选文字,再直接点「添加选区」(中间不要点别处)。
|
||||
chat-copy = 复制
|
||||
chat-copied = 已复制到剪贴板
|
||||
chat-empty-title = 开始对话
|
||||
chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。
|
||||
|
||||
itemmenu-chat = ChatPapers:打开对话
|
||||
itemmenu-compare = ChatPapers:多篇对照
|
||||
|
||||
multi-title = 多篇对照
|
||||
multi-empty-title = 多论文整体阅读
|
||||
multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。
|
||||
multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用
|
||||
multi-topic-review = 主题综述
|
||||
multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」
|
||||
multi-compare = 对比差异
|
||||
multi-summary-each = 各自摘要
|
||||
multi-common = 共同主题
|
||||
multi-close = 关闭
|
||||
multi-no-items = 请先选择文献
|
||||
multi-need-two = 请至少选择 2 篇文献再对照
|
||||
multi-too-many = 已选择 { $count } 篇,将使用前 8 篇
|
||||
multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
itemmenu-label = ChatPapers:总结
|
||||
itemmenu-chat = ChatPapers:打开对话
|
||||
itemmenu-compare = ChatPapers:多篇对照
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pref("openrouterTitle", "ChatPapers");
|
||||
pref("temperature", "0.3");
|
||||
pref("maxTokens", 2048);
|
||||
pref("timeoutMs", 120000);
|
||||
pref("maxContextChars", 80000);
|
||||
pref("maxContextChars", 100000);
|
||||
pref("answerLanguage", "zh-CN");
|
||||
pref("systemPrompt", "");
|
||||
pref("summaryPrompt", "");
|
||||
|
||||
Generated
+41
@@ -9,9 +9,12 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"katex": "^0.18.1",
|
||||
"lucide": "^1.25.0",
|
||||
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^24.10.0",
|
||||
"@zotero-plugin/eslint-config": "^0.6.7",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -1360,6 +1363,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/katex": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||
@@ -2860,6 +2870,31 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.1.tgz",
|
||||
"integrity": "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -2917,6 +2952,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lucide": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.25.0.tgz",
|
||||
"integrity": "sha512-Pg/9Ga1xTbrnI6GY/7J9krIYmJIkAf3PkK6wPmsAeUtAQsBdGxs1+dfvLqjf9nPI5aFm9+VquT75JGpO3QXSpQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
||||
+6
-1
@@ -29,9 +29,12 @@
|
||||
"update-deps": "npm update --save"
|
||||
},
|
||||
"dependencies": {
|
||||
"katex": "^0.18.1",
|
||||
"lucide": "^1.25.0",
|
||||
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^24.10.0",
|
||||
"@zotero-plugin/eslint-config": "^0.6.7",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -46,7 +49,9 @@
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.xhtml"],
|
||||
"files": [
|
||||
"*.xhtml"
|
||||
],
|
||||
"options": {
|
||||
"htmlWhitespaceSensitivity": "css"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { initLocale } from "./utils/locale";
|
||||
import { createZToolkit } from "./utils/ztoolkit";
|
||||
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
||||
import { registerPrefsScripts } from "./modules/ui/prefs";
|
||||
import { registerItemMenus } from "./modules/ui/itemMenu";
|
||||
import { registerReaderSelectionHook } from "./modules/pdf/selection";
|
||||
|
||||
async function onStartup() {
|
||||
await Promise.all([
|
||||
@@ -13,6 +15,7 @@ async function onStartup() {
|
||||
initLocale();
|
||||
registerPrefs();
|
||||
registerChatPane();
|
||||
registerReaderSelectionHook();
|
||||
|
||||
await Promise.all(
|
||||
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
||||
@@ -26,6 +29,7 @@ async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
|
||||
win.MozXULElement.insertFTLIfNeeded(
|
||||
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
||||
);
|
||||
registerItemMenus();
|
||||
}
|
||||
|
||||
async function onMainWindowUnload(_win: Window): Promise<void> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import { getPref } from "../../utils/prefs";
|
||||
|
||||
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题。
|
||||
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。论文材料会在会话开始时提供,并在整个对话中持续有效。
|
||||
要求:
|
||||
1. 回答准确、简洁,优先使用论文原文依据。
|
||||
2. 若信息不足,请明确说明,不要编造。
|
||||
3. 尽量标注页码或引用短片段(若上下文含页分隔)。
|
||||
4. 使用用户偏好的语言回答。`;
|
||||
1. 始终把已提供的【论文正文】/【论文 N】视为可用材料,直接据此回答或总结。
|
||||
2. 多轮对话时结合此前问答继续深入,不要假装没有论文或要求用户重新粘贴文本。
|
||||
3. 回答准确、简洁,优先引用正文依据;不要编造未出现的实验数据。
|
||||
4. 禁止要求用户「重新提供论文」或声称「只有参考文献/片段」。
|
||||
5. 使用用户偏好的语言回答。`;
|
||||
|
||||
const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含:
|
||||
const DEFAULT_SUMMARY = `请对【论文正文】做结构化总结,使用 Markdown,包含:
|
||||
## 一句话概括
|
||||
## 研究问题 / 动机
|
||||
## 方法
|
||||
## 主要结果
|
||||
## 贡献与创新
|
||||
## 局限与可追问点
|
||||
## 关键术语(可选)`;
|
||||
## 关键术语(可选)
|
||||
|
||||
注意:直接输出总结内容,不要先解释「文本是否完整」。`;
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
const custom = (getPref("systemPrompt") || "").trim();
|
||||
|
||||
+65
-31
@@ -1,5 +1,6 @@
|
||||
import type { ChatMessage } from "../llm/types";
|
||||
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
|
||||
import { assemblePaperChatMessages } from "../llm/history";
|
||||
import {
|
||||
buildMetadataBlock,
|
||||
extractPdfContext,
|
||||
@@ -10,6 +11,7 @@ export interface BuiltContext {
|
||||
messages: ChatMessage[];
|
||||
extract: ExtractResult;
|
||||
warning?: string;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export async function buildChatMessages(options: {
|
||||
@@ -23,55 +25,87 @@ export async function buildChatMessages(options: {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!extract.text) {
|
||||
warnings.push(
|
||||
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(
|
||||
`已智能截断并优先保留正文${rawInfo},来源 ${extract.source}${
|
||||
extract.pageCount ? ` · ${extract.pageCount} 页` : ""
|
||||
}。`,
|
||||
);
|
||||
} else {
|
||||
warnings.push(
|
||||
`已载入论文文本 ${extract.text.length} 字符(${extract.source}${
|
||||
extract.pageCount ? ` · ${extract.pageCount} 页` : ""
|
||||
})。`,
|
||||
);
|
||||
} else if (extract.truncated) {
|
||||
warnings.push("论文较长,已按设置截断上下文。");
|
||||
}
|
||||
|
||||
const meta = extract.parentItem
|
||||
? buildMetadataBlock(extract.parentItem)
|
||||
: "";
|
||||
|
||||
const systemParts = [getSystemPrompt()];
|
||||
if (meta) {
|
||||
systemParts.push(`论文元数据:\n${meta}`);
|
||||
}
|
||||
if (extract.text) {
|
||||
systemParts.push(
|
||||
`论文正文(页之间可能以换页符分隔):\n${extract.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemParts.join("\n\n") },
|
||||
];
|
||||
|
||||
// Keep recent history (exclude old system)
|
||||
const history = options.history
|
||||
.filter((m) => m.role !== "system")
|
||||
.slice(-20);
|
||||
messages.push(...history);
|
||||
const paperBlock = [
|
||||
meta ? `【论文元数据】\n${meta}` : "",
|
||||
`【论文正文】\n${extract.text}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
let taskBlock: string;
|
||||
if (options.mode === "summary") {
|
||||
const summaryAsk = getSummaryPrompt();
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: summaryAsk,
|
||||
});
|
||||
taskBlock = `【任务】
|
||||
请直接基于本会话开头已提供的【论文正文】完成结构化总结。
|
||||
严禁回复「请提供论文」「文本不完整无法总结」等推脱话术;信息不足时也请基于已有正文尽力总结,并简要注明局限。
|
||||
|
||||
${getSummaryPrompt()}`;
|
||||
} else {
|
||||
let content = options.userText.trim();
|
||||
let question = options.userText.trim();
|
||||
if (options.selection?.trim()) {
|
||||
content =
|
||||
`【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`;
|
||||
question = `【选中原文】\n${options.selection.trim()}\n\n【问题】\n${question}`;
|
||||
} else {
|
||||
question = `【问题】\n${question}`;
|
||||
}
|
||||
messages.push({ role: "user", content });
|
||||
taskBlock = `【任务】
|
||||
请直接基于本会话开头已提供的论文材料回答。
|
||||
严禁要求用户再次提供论文全文。
|
||||
|
||||
${question}`;
|
||||
}
|
||||
|
||||
const system = `${getSystemPrompt()}
|
||||
|
||||
硬性规则(必须遵守):
|
||||
1. 论文材料已在本会话首条用户消息中提供(【论文正文】);后续每一轮提问仍以该材料为准。
|
||||
2. 绝对禁止说「请提供论文文本」「只有引用/片段无法总结」之类的话。
|
||||
3. 若正文有截断标记,仍须基于已给出的章节作答,并可选一句说明「部分中间内容经抽样」。
|
||||
4. 结合此前对话继续深入回答,不要假装这是全新的、没有论文的会话。
|
||||
5. 不要把参考文献列表误判为「没有正文」。`;
|
||||
|
||||
const messages = assemblePaperChatMessages({
|
||||
system,
|
||||
paperBlock,
|
||||
taskBlock,
|
||||
history: options.history,
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
extract,
|
||||
warning: warnings.length ? warnings.join(" ") : undefined,
|
||||
ready: true,
|
||||
};
|
||||
}
|
||||
|
||||
+229
-35
@@ -4,29 +4,55 @@ export interface ExtractResult {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
pageCount?: number;
|
||||
source: "pdfworker" | "fulltext" | "empty";
|
||||
source: "pdfworker" | "attachmentText" | "reader" | "empty";
|
||||
attachment?: Zotero.Item;
|
||||
parentItem?: Zotero.Item;
|
||||
error?: string;
|
||||
/** Original extracted length before trim/truncate */
|
||||
rawLength?: number;
|
||||
/** Length after dropping references */
|
||||
bodyLength?: number;
|
||||
}
|
||||
|
||||
function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
|
||||
if (item.isAttachment() && item.attachmentContentType === "application/pdf") {
|
||||
return item;
|
||||
function resolvePDFWorker(): any {
|
||||
const candidates = [
|
||||
(Zotero as any).PDFWorker,
|
||||
(globalThis as any).Zotero?.PDFWorker,
|
||||
];
|
||||
try {
|
||||
candidates.push(ztoolkit.getGlobal("Zotero" as any)?.PDFWorker);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (item.isRegularItem()) {
|
||||
const attachments = item.getAttachments().map((id) => Zotero.Items.get(id));
|
||||
return attachments.find(
|
||||
(att) => att?.isAttachment() && att.attachmentContentType === "application/pdf",
|
||||
);
|
||||
try {
|
||||
const win = Zotero.getMainWindow?.() as any;
|
||||
if (win?.Zotero?.PDFWorker) candidates.push(win.Zotero.PDFWorker);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Child attachment of a parent
|
||||
if (item.isAttachment() && item.parentItemID) {
|
||||
const parent = Zotero.Items.get(item.parentItemID);
|
||||
return findPdfAttachment(parent);
|
||||
for (const worker of candidates) {
|
||||
if (worker?.getFullText) return worker;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPdfAttachment(item: any): boolean {
|
||||
if (!item || typeof item.isAttachment !== "function") return false;
|
||||
if (!item.isAttachment()) return false;
|
||||
try {
|
||||
if (typeof item.isPDFAttachment === "function") {
|
||||
return !!item.isPDFAttachment();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const type = String(item.attachmentContentType || "").toLowerCase();
|
||||
const name = String(
|
||||
item.attachmentFilename || item.getField?.("title") || "",
|
||||
).toLowerCase();
|
||||
return type === "application/pdf" || name.endsWith(".pdf");
|
||||
}
|
||||
|
||||
function getParentItem(item: Zotero.Item): Zotero.Item {
|
||||
if (item.isRegularItem()) return item;
|
||||
if (item.parentItemID) {
|
||||
@@ -35,25 +61,127 @@ function getParentItem(item: Zotero.Item): Zotero.Item {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Resolve the best PDF attachment for the current pane item / open reader. */
|
||||
export function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
|
||||
if (isPdfAttachment(item)) return item;
|
||||
|
||||
try {
|
||||
const readers =
|
||||
(Zotero.Reader as any).getAll?.() ||
|
||||
(Zotero.Reader as any)._readers ||
|
||||
[];
|
||||
for (const reader of readers) {
|
||||
const attID = reader?.itemID || reader?._itemID;
|
||||
if (!attID) continue;
|
||||
const att = Zotero.Items.get(attID) as Zotero.Item | false;
|
||||
if (!att || !isPdfAttachment(att)) continue;
|
||||
if (
|
||||
att.id === item.id ||
|
||||
att.parentItemID === item.id ||
|
||||
(item.parentItemID && att.parentItemID === item.parentItemID)
|
||||
) {
|
||||
return att;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ztoolkit.log("findPdfAttachment reader scan failed", e);
|
||||
}
|
||||
|
||||
if (item.isRegularItem()) {
|
||||
const ids = item.getAttachments();
|
||||
for (const id of ids) {
|
||||
const att = Zotero.Items.get(id) as Zotero.Item | false;
|
||||
if (att && isPdfAttachment(att)) return att;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isAttachment() && item.parentItemID) {
|
||||
const parent = Zotero.Items.get(item.parentItemID) as Zotero.Item | false;
|
||||
if (parent && parent.isRegularItem()) {
|
||||
return findPdfAttachment(parent);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeExtractedText(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
if (Array.isArray(raw)) return raw.join("").trim();
|
||||
if (typeof raw === "object" && raw !== null && "text" in (raw as object)) {
|
||||
return normalizeExtractedText((raw as { text: unknown }).text);
|
||||
}
|
||||
return String(raw).trim();
|
||||
}
|
||||
|
||||
async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
|
||||
const worker = (Zotero as any).PDFWorker;
|
||||
const worker = resolvePDFWorker();
|
||||
if (!worker?.getFullText) {
|
||||
throw new Error("PDFWorker unavailable");
|
||||
}
|
||||
const result = await worker.getFullText(attachment.id, null, true);
|
||||
return (result?.text || "").trim();
|
||||
}
|
||||
|
||||
async function extractViaFulltext(attachment: Zotero.Item): Promise<string> {
|
||||
const attempts: Array<() => Promise<unknown>> = [
|
||||
() => worker.getFullText(attachment.id),
|
||||
() => worker.getFullText(attachment.id, null, true),
|
||||
() => worker.getFullText(attachment.id, true),
|
||||
];
|
||||
|
||||
let lastError: unknown;
|
||||
for (const run of attempts) {
|
||||
try {
|
||||
const text = (attachment as any).attachmentText;
|
||||
if (typeof text === "string" && text.trim()) return text.trim();
|
||||
} catch {
|
||||
// ignore
|
||||
const result = await run();
|
||||
const text = normalizeExtractedText(result);
|
||||
if (text) return text;
|
||||
if (result != null) return "";
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
return "";
|
||||
}
|
||||
|
||||
async function extractViaAttachmentText(attachment: Zotero.Item): Promise<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(
|
||||
text: string,
|
||||
maxChars: number,
|
||||
@@ -61,13 +189,54 @@ export function truncateText(
|
||||
if (text.length <= maxChars) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
const head = Math.floor(maxChars * 0.7);
|
||||
const tail = maxChars - head - 80;
|
||||
const sliced =
|
||||
text.slice(0, head) +
|
||||
"\n\n[... 中间内容已截断 ...]\n\n" +
|
||||
text.slice(-Math.max(tail, 0));
|
||||
return { text: sliced, truncated: true };
|
||||
|
||||
const { body, removedRefs } = stripReferencesSection(text);
|
||||
const source = body.length > 1000 ? body : text;
|
||||
|
||||
if (source.length <= maxChars) {
|
||||
const note = removedRefs
|
||||
? "\n\n[注:已省略文末 References/参考文献 以保留正文。]"
|
||||
: "";
|
||||
return { text: source + note, truncated: removedRefs };
|
||||
}
|
||||
|
||||
// Budget: 40% head (title/abstract/intro), 45% middle samples, 15% tail (conclusion)
|
||||
const headBudget = Math.floor(maxChars * 0.4);
|
||||
const midBudget = Math.floor(maxChars * 0.45);
|
||||
const tailBudget = Math.max(maxChars - headBudget - midBudget - 120, 800);
|
||||
|
||||
const head = source.slice(0, headBudget);
|
||||
const tail = source.slice(-tailBudget);
|
||||
const midRegionStart = headBudget;
|
||||
const midRegionEnd = Math.max(midRegionStart, source.length - tailBudget);
|
||||
const midRegion = source.slice(midRegionStart, midRegionEnd);
|
||||
|
||||
const chunks: string[] = [];
|
||||
const chunkCount = 4;
|
||||
const chunkSize = Math.floor(midBudget / chunkCount);
|
||||
if (midRegion.length > 0 && chunkSize > 0) {
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
const start = Math.floor((midRegion.length * i) / chunkCount);
|
||||
chunks.push(midRegion.slice(start, start + chunkSize));
|
||||
}
|
||||
}
|
||||
|
||||
const sliced = [
|
||||
head,
|
||||
"",
|
||||
"[... 中间部分抽样保留(方法/实验/结果)...]",
|
||||
"",
|
||||
chunks.filter(Boolean).join("\n\n[...]\n\n"),
|
||||
"",
|
||||
"[... 后文(结论附近)...]",
|
||||
"",
|
||||
tail,
|
||||
removedRefs ? "[注:已省略文末 References/参考文献。]" : "",
|
||||
]
|
||||
.filter((s, idx, arr) => !(s === "" && arr[idx - 1] === ""))
|
||||
.join("\n");
|
||||
|
||||
return { text: sliced.slice(0, maxChars), truncated: true };
|
||||
}
|
||||
|
||||
export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
|
||||
@@ -79,30 +248,52 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
|
||||
truncated: false,
|
||||
source: "empty",
|
||||
parentItem,
|
||||
error: "当前条目下未找到 PDF 附件。",
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let raw = "";
|
||||
let source: ExtractResult["source"] = "empty";
|
||||
|
||||
try {
|
||||
raw = await extractViaPdfWorker(attachment);
|
||||
if (raw) source = "pdfworker";
|
||||
} catch (e) {
|
||||
errors.push(`PDFWorker: ${String(e)}`);
|
||||
ztoolkit.log("PDFWorker extract failed", e);
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
try {
|
||||
raw = await extractViaFulltext(attachment);
|
||||
if (raw) source = "fulltext";
|
||||
raw = await extractViaAttachmentText(attachment);
|
||||
if (raw) source = "attachmentText";
|
||||
} 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 pageCount = raw ? raw.split("\f").length : undefined;
|
||||
const pageCount = raw.includes("\f") ? raw.split("\f").length : undefined;
|
||||
|
||||
return {
|
||||
text,
|
||||
@@ -111,13 +302,16 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
|
||||
source,
|
||||
attachment,
|
||||
parentItem,
|
||||
rawLength,
|
||||
bodyLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMetadataBlock(item: Zotero.Item): string {
|
||||
if (!getPref("sendMetadata")) return "";
|
||||
const title = item.getField("title") || "";
|
||||
const creators = item.getCreators()
|
||||
const creators = item
|
||||
.getCreators()
|
||||
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+225
-60
@@ -1,10 +1,28 @@
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Copy,
|
||||
Cpu,
|
||||
Eraser,
|
||||
Highlighter,
|
||||
MessageSquareText,
|
||||
NotebookPen,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
User,
|
||||
type IconNode,
|
||||
} from "lucide";
|
||||
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||
import { LlmError, type ChatMessage } from "../llm/types";
|
||||
import { getProviderPreset } from "../llm/providers";
|
||||
import { buildChatMessages } from "../pdf/context";
|
||||
import { getReaderSelectedText } from "../pdf/selection";
|
||||
import { clearSession, loadSession, saveSession } from "../storage/sessions";
|
||||
import { createChildNote } from "../zotero/notes";
|
||||
import { getString } from "../../utils/locale";
|
||||
import { markdownToHtml } from "../../utils/markdown";
|
||||
import { createLucideIcon } from "../../utils/icons";
|
||||
import {
|
||||
createAbortHandle,
|
||||
isAbortError,
|
||||
@@ -22,8 +40,11 @@ export class ChatView {
|
||||
private messagesEl!: HTMLElement;
|
||||
private inputEl!: HTMLTextAreaElement;
|
||||
private statusEl!: HTMLElement;
|
||||
private statusIconEl!: HTMLElement;
|
||||
private statusTextEl!: HTMLElement;
|
||||
private sendBtn!: HTMLButtonElement;
|
||||
private stopBtn!: HTMLButtonElement;
|
||||
private emptyEl!: HTMLElement;
|
||||
|
||||
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
|
||||
this.doc = doc;
|
||||
@@ -40,36 +61,78 @@ export class ChatView {
|
||||
const providerLabel = preset?.label || cfg.provider || "未配置";
|
||||
|
||||
const header = this.el("div", "chatpapers-header");
|
||||
header.append(
|
||||
this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"),
|
||||
this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`),
|
||||
const brand = this.el("div", "chatpapers-brand");
|
||||
brand.append(
|
||||
createLucideIcon(this.doc, MessageSquareText, {
|
||||
size: 18,
|
||||
className: "chatpapers-brand-icon",
|
||||
}),
|
||||
this.el("div", "chatpapers-brand-text", "ChatPapers"),
|
||||
);
|
||||
const titleRow = this.el("div", "chatpapers-title-row");
|
||||
titleRow.append(
|
||||
this.el(
|
||||
"div",
|
||||
"chatpapers-title",
|
||||
this.item.getField("title") || "ChatPapers",
|
||||
),
|
||||
);
|
||||
const meta = this.el("div", "chatpapers-meta");
|
||||
meta.append(
|
||||
createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }),
|
||||
this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`),
|
||||
);
|
||||
header.append(brand, titleRow, meta);
|
||||
|
||||
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||
this.emptyEl.append(
|
||||
createLucideIcon(this.doc, Sparkles, {
|
||||
size: 28,
|
||||
className: "chatpapers-empty-icon",
|
||||
}),
|
||||
this.el("div", "chatpapers-empty-title", getString("chat-empty-title")),
|
||||
this.el("div", "chatpapers-empty-desc", getString("chat-empty-desc")),
|
||||
);
|
||||
|
||||
this.statusEl = this.el("div", "chatpapers-status");
|
||||
this.statusIconEl = this.el("span", "chatpapers-status-icon");
|
||||
this.statusTextEl = this.el("span", "chatpapers-status-text");
|
||||
this.statusEl.append(this.statusIconEl, this.statusTextEl);
|
||||
|
||||
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||
const summarizeBtn = this.btn(getString("chat-summarize"), () =>
|
||||
this.runSummary(),
|
||||
toolbar.append(
|
||||
this.iconBtn(Sparkles, getString("chat-summarize"), () => this.runSummary()),
|
||||
this.iconBtn(
|
||||
Highlighter,
|
||||
getString("chat-add-selection"),
|
||||
() => this.captureSelection(),
|
||||
undefined,
|
||||
{ preserveSelection: true },
|
||||
),
|
||||
this.iconBtn(Eraser, getString("chat-clear"), () => this.clearChat()),
|
||||
this.iconBtn(NotebookPen, getString("chat-save-note"), () =>
|
||||
this.saveLastNote(),
|
||||
),
|
||||
);
|
||||
const addSelBtn = this.btn(getString("chat-add-selection"), () =>
|
||||
this.captureSelection(),
|
||||
);
|
||||
const clearBtn = this.btn(getString("chat-clear"), () => this.clearChat());
|
||||
const saveBtn = this.btn(getString("chat-save-note"), () => this.saveLastNote());
|
||||
toolbar.append(summarizeBtn, addSelBtn, clearBtn, saveBtn);
|
||||
|
||||
const composer = this.el("div", "chatpapers-composer");
|
||||
this.inputEl = this.doc.createElement("textarea");
|
||||
this.inputEl.className = "chatpapers-input";
|
||||
this.inputEl.rows = 3;
|
||||
this.inputEl.placeholder = getString("chat-placeholder");
|
||||
|
||||
const actions = this.el("div", "chatpapers-actions");
|
||||
this.sendBtn = this.btn(getString("chat-send"), () => this.send());
|
||||
this.sendBtn.classList.add("chatpapers-primary");
|
||||
this.stopBtn = this.btn(getString("chat-stop"), () => this.stop());
|
||||
this.sendBtn = this.iconBtn(
|
||||
SendHorizontal,
|
||||
getString("chat-send"),
|
||||
() => this.send(),
|
||||
"chatpapers-primary",
|
||||
);
|
||||
this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => this.stop());
|
||||
this.stopBtn.disabled = true;
|
||||
actions.append(this.sendBtn, this.stopBtn);
|
||||
composer.append(this.inputEl, actions);
|
||||
|
||||
this.inputEl.addEventListener("keydown", (ev) => {
|
||||
const kev = ev as KeyboardEvent;
|
||||
@@ -79,7 +142,13 @@ export class ChatView {
|
||||
}
|
||||
});
|
||||
|
||||
this.body.append(header, this.messagesEl, this.statusEl, toolbar, this.inputEl, actions);
|
||||
this.body.append(
|
||||
header,
|
||||
this.messagesEl,
|
||||
this.statusEl,
|
||||
toolbar,
|
||||
composer,
|
||||
);
|
||||
|
||||
const keys = this.sessionKeys();
|
||||
this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
|
||||
@@ -95,15 +164,12 @@ export class ChatView {
|
||||
}
|
||||
|
||||
private sessionKeys(): { itemKey: string; attachmentKey: string } {
|
||||
const parent =
|
||||
this.item.isRegularItem()
|
||||
const parent = this.item.isRegularItem()
|
||||
? this.item
|
||||
: this.item.parentItemID
|
||||
? Zotero.Items.get(this.item.parentItemID)
|
||||
: this.item;
|
||||
const attachment = this.item.isAttachment()
|
||||
? this.item
|
||||
: undefined;
|
||||
const attachment = this.item.isAttachment() ? this.item : undefined;
|
||||
return {
|
||||
itemKey: parent.key,
|
||||
attachmentKey: attachment?.key || "",
|
||||
@@ -117,67 +183,154 @@ export class ChatView {
|
||||
return node;
|
||||
}
|
||||
|
||||
private btn(label: string, onClick: () => void): HTMLButtonElement {
|
||||
private iconBtn(
|
||||
icon: IconNode,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
extraClass?: string,
|
||||
options?: { preserveSelection?: boolean },
|
||||
): HTMLButtonElement {
|
||||
const b = this.doc.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chatpapers-btn";
|
||||
b.textContent = label;
|
||||
b.className = extraClass
|
||||
? `chatpapers-btn ${extraClass}`
|
||||
: "chatpapers-btn";
|
||||
b.title = label;
|
||||
b.append(
|
||||
createLucideIcon(this.doc, icon, { size: 15 }),
|
||||
this.el("span", "chatpapers-btn-label", label),
|
||||
);
|
||||
if (options?.preserveSelection) {
|
||||
// Prevent focus steal that clears PDF text selection
|
||||
b.addEventListener("mousedown", (ev) => {
|
||||
if ((ev as MouseEvent).button !== 0) return;
|
||||
ev.preventDefault();
|
||||
onClick();
|
||||
});
|
||||
} else {
|
||||
b.addEventListener("click", onClick);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") {
|
||||
this.statusEl.textContent = text;
|
||||
private setStatus(
|
||||
text: string,
|
||||
kind: "info" | "warn" | "error" | "" = "info",
|
||||
) {
|
||||
this.statusTextEl.textContent = text;
|
||||
this.statusEl.dataset.kind = kind;
|
||||
this.statusIconEl.replaceChildren();
|
||||
if (!text) return;
|
||||
const icon =
|
||||
kind === "error" || kind === "warn"
|
||||
? Sparkles
|
||||
: Check;
|
||||
this.statusIconEl.append(
|
||||
createLucideIcon(this.doc, icon, { size: 13 }),
|
||||
);
|
||||
}
|
||||
|
||||
private renderMessages() {
|
||||
this.messagesEl.replaceChildren();
|
||||
for (const msg of this.messages) {
|
||||
if (msg.role === "system") continue;
|
||||
const visible = this.messages.filter((m) => m.role !== "system");
|
||||
if (!visible.length) {
|
||||
this.messagesEl.append(this.emptyEl);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of visible) {
|
||||
const bubble = this.el(
|
||||
"div",
|
||||
`chatpapers-bubble chatpapers-${msg.role}`,
|
||||
);
|
||||
const role = this.el(
|
||||
"div",
|
||||
"chatpapers-role",
|
||||
msg.role === "user" ? "You" : "AI",
|
||||
const roleRow = this.el("div", "chatpapers-role");
|
||||
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||
roleLeft.append(
|
||||
createLucideIcon(this.doc, msg.role === "user" ? User : Bot, {
|
||||
size: 13,
|
||||
className: "chatpapers-role-icon",
|
||||
}),
|
||||
this.el("span", undefined, msg.role === "user" ? "You" : "AI"),
|
||||
);
|
||||
const content = this.el("div", "chatpapers-content", msg.content);
|
||||
bubble.append(role, content);
|
||||
roleRow.append(roleLeft);
|
||||
if (msg.role === "assistant" && msg.content) {
|
||||
const copyBtn = this.doc.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.className = "chatpapers-copy";
|
||||
copyBtn.title = getString("chat-copy");
|
||||
copyBtn.append(
|
||||
createLucideIcon(this.doc, Copy, { size: 12 }),
|
||||
this.el("span", undefined, getString("chat-copy")),
|
||||
);
|
||||
copyBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.copyText(msg.content);
|
||||
});
|
||||
roleRow.append(copyBtn);
|
||||
}
|
||||
const content = this.el("div", "chatpapers-content");
|
||||
this.fillContent(content, msg.content, msg.role === "assistant");
|
||||
bubble.append(roleRow, content);
|
||||
this.messagesEl.append(bubble);
|
||||
}
|
||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) {
|
||||
if (asMarkdown) {
|
||||
el.classList.add("chatpapers-md");
|
||||
el.innerHTML = markdownToHtml(text || "");
|
||||
} else {
|
||||
el.classList.remove("chatpapers-md");
|
||||
el.textContent = text || "";
|
||||
}
|
||||
}
|
||||
|
||||
private copyText(text: string) {
|
||||
try {
|
||||
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
|
||||
this.setStatus(getString("chat-copied"), "info");
|
||||
return;
|
||||
} catch (e) {
|
||||
ztoolkit.log("Clipboard helper failed", e);
|
||||
}
|
||||
try {
|
||||
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
|
||||
this.setStatus(getString("chat-copied"), "info");
|
||||
} catch (e) {
|
||||
ztoolkit.log(e);
|
||||
this.setStatus(String(e), "error");
|
||||
}
|
||||
}
|
||||
|
||||
private appendAssistantPlaceholder(): HTMLElement {
|
||||
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
|
||||
bubble.append(
|
||||
this.el("div", "chatpapers-role", "AI"),
|
||||
this.el("div", "chatpapers-content", ""),
|
||||
const roleRow = this.el("div", "chatpapers-role");
|
||||
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||
roleLeft.append(
|
||||
createLucideIcon(this.doc, Bot, { size: 13 }),
|
||||
this.el("span", undefined, "AI"),
|
||||
);
|
||||
roleRow.append(roleLeft);
|
||||
const content = this.el("div", "chatpapers-content chatpapers-md");
|
||||
bubble.append(roleRow, content);
|
||||
this.messagesEl.append(bubble);
|
||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||
return bubble.querySelector(".chatpapers-content") as HTMLElement;
|
||||
return content;
|
||||
}
|
||||
|
||||
private captureSelection() {
|
||||
try {
|
||||
const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID);
|
||||
// @ts-expect-error internal reader APIs vary
|
||||
const win = reader?._iframeWindow || reader?._internalReader?._primaryView?._iframe?.contentWindow;
|
||||
const sel = win?.getSelection?.()?.toString?.() || "";
|
||||
if (!sel.trim()) {
|
||||
// try main window selection as fallback
|
||||
const mainSel = this.doc.defaultView?.getSelection?.()?.toString?.() || "";
|
||||
this.selectionText = mainSel.trim();
|
||||
} else {
|
||||
this.selectionText = sel.trim();
|
||||
}
|
||||
const text = getReaderSelectedText();
|
||||
this.selectionText = text;
|
||||
if (this.selectionText) {
|
||||
const preview =
|
||||
this.selectionText.length > 80
|
||||
? `${this.selectionText.slice(0, 80)}…`
|
||||
: this.selectionText;
|
||||
this.setStatus(
|
||||
`${getString("chat-selection-added")} (${this.selectionText.length} chars)`,
|
||||
`${getString("chat-selection-added")} (${this.selectionText.length} chars): ${preview}`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
@@ -233,32 +386,43 @@ export class ChatView {
|
||||
});
|
||||
this.selectionText = "";
|
||||
|
||||
if (built.warning) {
|
||||
this.setStatus(built.warning, "warn");
|
||||
if (!built.ready) {
|
||||
this.setStatus(built.warning || "无法读取 PDF 文本", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "chat") {
|
||||
this.messages.push({ role: "user", content: userText });
|
||||
} else {
|
||||
this.messages.push({ role: "user", content: userText });
|
||||
if (built.warning) {
|
||||
this.setStatus(built.warning, "info");
|
||||
}
|
||||
|
||||
this.messages.push({ role: "user", content: userText });
|
||||
this.renderMessages();
|
||||
|
||||
const contentEl = this.appendAssistantPlaceholder();
|
||||
let full = "";
|
||||
let lastRender = 0;
|
||||
await chatStream({
|
||||
messages: built.messages,
|
||||
signal: this.abort.signal,
|
||||
onDelta: (delta) => {
|
||||
full += delta;
|
||||
contentEl.textContent = full;
|
||||
const now = Date.now();
|
||||
if (now - lastRender > 80) {
|
||||
lastRender = now;
|
||||
this.fillContent(contentEl, full, true);
|
||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||
}
|
||||
},
|
||||
});
|
||||
this.fillContent(contentEl, full || "(empty)", true);
|
||||
|
||||
this.messages.push({ role: "assistant", content: full || "(empty)" });
|
||||
await this.persist();
|
||||
this.setStatus(getString("chat-done"), "info");
|
||||
const chars = built.extract.text?.length || 0;
|
||||
this.setStatus(
|
||||
`${getString("chat-done")} · 已基于 ${chars} 字符论文文本`,
|
||||
"info",
|
||||
);
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
this.setStatus(getString("chat-stopped"), "warn");
|
||||
@@ -283,13 +447,14 @@ export class ChatView {
|
||||
}
|
||||
|
||||
private async saveLastNote() {
|
||||
const last = [...this.messages].reverse().find((m) => m.role === "assistant");
|
||||
const last = [...this.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
if (!last) {
|
||||
this.setStatus(getString("chat-no-reply"), "warn");
|
||||
return;
|
||||
}
|
||||
const parent =
|
||||
this.item.isRegularItem()
|
||||
const parent = this.item.isRegularItem()
|
||||
? this.item
|
||||
: this.item.parentItemID
|
||||
? Zotero.Items.get(this.item.parentItemID)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { markdownToNoteHtml } from "../../utils/markdown";
|
||||
|
||||
export async function createChildNote(options: {
|
||||
parentItem: Zotero.Item;
|
||||
title: string;
|
||||
@@ -7,94 +9,8 @@ export async function createChildNote(options: {
|
||||
note.libraryID = options.parentItem.libraryID;
|
||||
note.parentID = options.parentItem.id;
|
||||
|
||||
const html = markdownToSimpleHtml(options.title, options.bodyMarkdown);
|
||||
const html = markdownToNoteHtml(options.title, options.bodyMarkdown);
|
||||
note.setNote(html);
|
||||
await note.saveTx();
|
||||
return note;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Minimal markdown → HTML for Zotero notes */
|
||||
export function markdownToSimpleHtml(title: string, md: string): string {
|
||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||
const parts: string[] = [
|
||||
`<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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Markdown + LaTeX → HTML for chat bubbles and Zotero notes.
|
||||
* LaTeX is rendered to MathML via KaTeX (native in Firefox / Zotero).
|
||||
*/
|
||||
|
||||
import katex from "katex";
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderTex(tex: string, displayMode: boolean): string {
|
||||
const cleaned = tex.trim();
|
||||
if (!cleaned) return "";
|
||||
try {
|
||||
const mathml = katex.renderToString(cleaned, {
|
||||
throwOnError: false,
|
||||
displayMode,
|
||||
output: "mathml",
|
||||
strict: "ignore",
|
||||
trust: false,
|
||||
});
|
||||
const cls = displayMode
|
||||
? "chatpapers-math chatpapers-math-block"
|
||||
: "chatpapers-math chatpapers-math-inline";
|
||||
return `<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("");
|
||||
}
|
||||
Vendored
+20
@@ -6,7 +6,11 @@ export type FluentMessageId =
|
||||
| 'chat-add-selection'
|
||||
| 'chat-clear'
|
||||
| 'chat-cleared'
|
||||
| 'chat-copied'
|
||||
| 'chat-copy'
|
||||
| 'chat-done'
|
||||
| 'chat-empty-desc'
|
||||
| 'chat-empty-title'
|
||||
| 'chat-loading'
|
||||
| 'chat-need-config'
|
||||
| 'chat-no-reply'
|
||||
@@ -22,7 +26,23 @@ export type FluentMessageId =
|
||||
| 'chat-thinking'
|
||||
| 'item-section-chat-head'
|
||||
| 'item-section-chat-sidenav'
|
||||
| 'itemmenu-chat'
|
||||
| 'itemmenu-compare'
|
||||
| 'itemmenu-label'
|
||||
| 'multi-close'
|
||||
| 'multi-common'
|
||||
| 'multi-compare'
|
||||
| 'multi-empty-desc'
|
||||
| 'multi-empty-title'
|
||||
| 'multi-need-topic'
|
||||
| 'multi-need-two'
|
||||
| 'multi-no-items'
|
||||
| 'multi-placeholder'
|
||||
| 'multi-ready'
|
||||
| 'multi-summary-each'
|
||||
| 'multi-title'
|
||||
| 'multi-too-many'
|
||||
| 'multi-topic-review'
|
||||
| 'prefs-answer-language'
|
||||
| 'prefs-api-key'
|
||||
| 'prefs-base-url'
|
||||
|
||||
Reference in New Issue
Block a user