From 3d5ffa1cb683dbf2c57ce120ab45f6c380f76e90 Mon Sep 17 00:00:00 2001 From: yehongyu Date: Mon, 20 Jul 2026 15:55:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=8F=AF=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=9A=84xpi=E7=9A=84=E6=8F=92=E4=BB=B6=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/content/chatpapers.css | 212 ++++++++++++- addon/locale/en-US/addon.ftl | 28 +- addon/locale/en-US/mainWindow.ftl | 4 +- addon/locale/zh-CN/addon.ftl | 28 +- addon/locale/zh-CN/mainWindow.ftl | 4 +- scripts/esbuild-fix-toolkit-import.mjs | 40 +++ src/modules/ui/itemMenu.ts | 32 +- src/modules/ui/multiChatDialog.ts | 7 +- src/modules/ui/multiChatView.ts | 399 ++++++++++++++++++++++--- src/modules/ui/pickItems.ts | 56 ++++ src/modules/zotero/citeExport.ts | 326 ++++++++++++++++++++ src/utils/markdown.ts | 11 +- typings/i10n.d.ts | 13 + zotero-plugin.config.ts | 2 + 14 files changed, 1066 insertions(+), 96 deletions(-) create mode 100644 scripts/esbuild-fix-toolkit-import.mjs create mode 100644 src/modules/ui/pickItems.ts create mode 100644 src/modules/zotero/citeExport.ts diff --git a/addon/content/chatpapers.css b/addon/content/chatpapers.css index 851e783..6f40c77 100644 --- a/addon/content/chatpapers.css +++ b/addon/content/chatpapers.css @@ -141,17 +141,41 @@ color: #2563eb; } -.chatpapers-toolbar, -.chatpapers-actions { +.chatpapers-toolbar { display: flex; flex-wrap: wrap; gap: 6px; } .chatpapers-composer { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 8px; +} + +.chatpapers-composer .chatpapers-input { + flex: 1 1 auto; + width: auto; + min-width: 0; + min-height: 72px; + resize: vertical; +} + +.chatpapers-actions { display: flex; flex-direction: column; + justify-content: flex-end; + flex-wrap: nowrap; gap: 6px; + flex-shrink: 0; + align-self: stretch; +} + +.chatpapers-actions .chatpapers-btn { + justify-content: center; + min-width: 4.5em; + white-space: nowrap; } .chatpapers-btn { @@ -484,6 +508,7 @@ .chatpapers-multi-root .chatpapers-header, .chatpapers-multi-root .chatpapers-paper-chips, +.chatpapers-multi-root .chatpapers-cite-bar, .chatpapers-multi-root .chatpapers-status, .chatpapers-multi-root .chatpapers-toolbar, .chatpapers-multi-root .chatpapers-composer { @@ -503,10 +528,14 @@ .chatpapers-multi-root .chatpapers-composer { margin-top: 2px; - padding-top: 4px; + padding-top: 8px; border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); } +.chatpapers-multi-root .chatpapers-composer .chatpapers-input { + min-height: 80px; +} + .chatpapers-multi-root .chatpapers-toolbar { padding-top: 2px; } @@ -539,18 +568,18 @@ display: flex; flex-wrap: wrap; gap: 6px; - max-height: 72px; + max-height: 88px; overflow: auto; margin-bottom: 2px; scrollbar-width: thin; + align-items: center; } .chatpapers-chip { - display: inline-block; - max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 260px; font-size: 11px; padding: 3px 8px; border-radius: 999px; @@ -558,3 +587,168 @@ background: color-mix(in srgb, #2563eb 10%, transparent); color: inherit; } + +.chatpapers-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 210px; +} + +.chatpapers-chip-remove { + appearance: none; + border: none; + background: transparent; + color: inherit; + padding: 0; + margin: 0; + width: 16px; + height: 16px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.65; + flex-shrink: 0; +} + +.chatpapers-chip-remove:hover { + opacity: 1; + background: color-mix(in srgb, currentColor 12%, transparent); +} + +.chatpapers-chip-add { + cursor: pointer; + background: transparent; + border-style: dashed; + max-width: none; + font: inherit; + color: inherit; +} + +.chatpapers-chip-add:hover:not(:disabled) { + background: color-mix(in srgb, #2563eb 12%, transparent); +} + +.chatpapers-chip-add:disabled { + opacity: 0.45; + cursor: default; +} + +.chatpapers-cite-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + padding: 2px 0 4px; + position: relative; + z-index: 5; +} + +.chatpapers-cite-label { + font-size: 12px; + opacity: 0.8; + flex-shrink: 0; +} + +.chatpapers-cite-picker { + position: relative; + flex: 1 1 180px; + min-width: 140px; + max-width: 420px; +} + +.chatpapers-cite-trigger { + appearance: none; + width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font: inherit; + font-size: 12px; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: color-mix(in srgb, currentColor 4%, transparent); + color: inherit; + cursor: pointer; + text-align: left; +} + +.chatpapers-cite-trigger:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.chatpapers-cite-trigger:disabled { + opacity: 0.55; + cursor: default; +} + +.chatpapers-cite-trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.chatpapers-cite-chevron { + flex-shrink: 0; + opacity: 0.7; +} + +.chatpapers-cite-picker.is-open .chatpapers-cite-chevron { + transform: rotate(180deg); +} + +.chatpapers-cite-menu { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 4px); + max-height: 220px; + overflow: auto; + z-index: 40; + border-radius: 8px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: Canvas; + color: CanvasText; + box-shadow: 0 8px 24px color-mix(in srgb, #000 18%, transparent); + padding: 4px; + scrollbar-width: thin; +} + +.chatpapers-cite-option { + appearance: none; + display: block; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 12px; + text-align: left; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chatpapers-cite-option:hover { + background: color-mix(in srgb, #2563eb 14%, transparent); +} + +.chatpapers-cite-option.is-active { + background: color-mix(in srgb, #2563eb 22%, transparent); + font-weight: 600; +} + +.chatpapers-cite-btn { + flex-shrink: 0; +} diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index 99b493e..c15187c 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -27,12 +27,12 @@ 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-chat = ChatPapers: 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-title = ChatPapers chat +multi-empty-title = Paper chat +multi-empty-desc = Ask about one paper, or click Add papers to bring more from your library for comparison and topic review. multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation multi-topic-review = Topic review multi-need-topic = Enter a research topic in the box, then click Topic review @@ -41,6 +41,20 @@ 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 +multi-need-two = Add at least 2 papers for comparison actions (use Add papers) +multi-too-many = Up to 8 papers; currently { $count } +multi-ready = { $count } paper(s) loaded — chat away, or Add papers to expand +multi-add-paper = Add papers +multi-remove-paper = Remove from chat +multi-add-none = No new papers added (may already be in the chat) +multi-paper-added = Added { $added }; now { $count } papers +multi-paper-removed = Removed; now { $count } papers + +cite-style = Citation style +cite-copy = Copy bibliography +cite-export = Export file +cite-loading = Loading styles… +cite-empty = No styles available +cite-copied = Bibliography copied +cite-exported = Bibliography exported +cite-export-cancel = Export cancelled diff --git a/addon/locale/en-US/mainWindow.ftl b/addon/locale/en-US/mainWindow.ftl index 6e5ca86..eece86b 100644 --- a/addon/locale/en-US/mainWindow.ftl +++ b/addon/locale/en-US/mainWindow.ftl @@ -1,3 +1,3 @@ -itemmenu-label = ChatPapers: Summarize -itemmenu-chat = ChatPapers: Open chat +itemmenu-label = ChatPapers +itemmenu-chat = ChatPapers: Chat itemmenu-compare = ChatPapers: Compare papers diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index fa3aed7..05e9c4e 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -27,12 +27,12 @@ chat-copied = 已复制到剪贴板 chat-empty-title = 开始对话 chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。 -itemmenu-chat = ChatPapers:打开对话 +itemmenu-chat = ChatPapers:对话 itemmenu-compare = ChatPapers:多篇对照 -multi-title = 多篇对照 -multi-empty-title = 多论文整体阅读 -multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。 +multi-title = ChatPapers 对话 +multi-empty-title = 文献对话 +multi-empty-desc = 单篇可直接提问;点「添加文献」从文库加入更多论文后可对比与主题综述。 multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用 multi-topic-review = 主题综述 multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」 @@ -41,7 +41,21 @@ multi-summary-each = 各自摘要 multi-common = 共同主题 multi-close = 关闭 multi-no-items = 请先选择文献 -multi-need-two = 请至少选择 2 篇文献再对照 -multi-too-many = 已选择 { $count } 篇,将使用前 8 篇 -multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述 +multi-need-two = 请至少加入 2 篇文献后再做对比类操作(可点「添加文献」) +multi-too-many = 最多支持 8 篇,当前已选 { $count } 篇 +multi-ready = 已载入 { $count } 篇文献,可开始对话;可用「添加文献」扩充 +multi-add-paper = 添加文献 +multi-remove-paper = 移出对话 +multi-add-none = 未加入新文献(可能已在对话中) +multi-paper-added = 已加入 { $added } 篇,当前共 { $count } 篇 +multi-paper-removed = 已移出,当前共 { $count } 篇 + +cite-style = 引用样式 +cite-copy = 复制参考文献 +cite-export = 导出文件 +cite-loading = 加载样式… +cite-empty = 无可用样式 +cite-copied = 参考文献已复制到剪贴板 +cite-exported = 参考文献已导出 +cite-export-cancel = 已取消导出 diff --git a/addon/locale/zh-CN/mainWindow.ftl b/addon/locale/zh-CN/mainWindow.ftl index 158a7ab..57891f9 100644 --- a/addon/locale/zh-CN/mainWindow.ftl +++ b/addon/locale/zh-CN/mainWindow.ftl @@ -1,3 +1,3 @@ -itemmenu-label = ChatPapers:总结 -itemmenu-chat = ChatPapers:打开对话 +itemmenu-label = ChatPapers +itemmenu-chat = ChatPapers:对话 itemmenu-compare = ChatPapers:多篇对照 diff --git a/scripts/esbuild-fix-toolkit-import.mjs b/scripts/esbuild-fix-toolkit-import.mjs new file mode 100644 index 0000000..473ff78 --- /dev/null +++ b/scripts/esbuild-fix-toolkit-import.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs/promises"; + +/** + * zotero-plugin-toolkit's `_importESModule` still prefers ChromeUtils.import + * when that property exists. On Zotero 9 / Firefox 128+, `import` remains as a + * stub that throws "ChromeUtils.import() has been removed". + */ +export function fixToolkitImportESModule() { + return { + name: "chatpapers-fix-toolkit-importesmodule", + setup(build) { + build.onLoad( + { filter: /zotero-plugin-toolkit[\\/]dist[\\/]index\.js$/ }, + async (args) => { + const text = await fs.readFile(args.path, "utf8"); + const next = text.replace( + /function _importESModule\(path\) \{[\s\S]*?\n\}/, + `function _importESModule(path) { +\tif (typeof ChromeUtils.importESModule === "function") { +\t\ttry { +\t\t\treturn ChromeUtils.importESModule(path, { global: "contextual" }); +\t\t} catch (e) { +\t\t\treturn ChromeUtils.importESModule(path); +\t\t} +\t} +\tif (path.endsWith(".sys.mjs")) path = path.replace(/\\.sys\\.mjs$/, ".jsm"); +\treturn ChromeUtils.import(path); +}`, + ); + if (next === text) { + console.warn( + "[chatpapers] toolkit _importESModule patch did not match; check toolkit version", + ); + } + return { contents: next, loader: "js" }; + }, + ); + }, + }; +} diff --git a/src/modules/ui/itemMenu.ts b/src/modules/ui/itemMenu.ts index 02dcd86..a80c684 100644 --- a/src/modules/ui/itemMenu.ts +++ b/src/modules/ui/itemMenu.ts @@ -3,6 +3,7 @@ import { getString } from "../../utils/locale"; import { openMultiChatDialog } from "./multiChatDialog"; const MENU_CHAT_ID = "chatpapers-itemmenu-chat"; +/** Legacy duplicate menu — unregister if still present from older builds. */ const MENU_COMPARE_ID = "chatpapers-itemmenu-compare"; function getSelectedItems(): Zotero.Item[] { @@ -15,7 +16,18 @@ function getSelectedItems(): Zotero.Item[] { } } +function removeMenuById(id: string) { + try { + const doc = Zotero.getMainWindow()?.document; + doc?.getElementById(id)?.remove(); + } catch { + // ignore + } +} + export function registerItemMenus() { + removeMenuById(MENU_COMPARE_ID); + const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`; ztoolkit.Menu.register("item", { @@ -28,24 +40,4 @@ export function registerItemMenus() { openMultiChatDialog(items); }, }); - - ztoolkit.Menu.register("item", { - tag: "menuitem", - id: MENU_COMPARE_ID, - label: getString("itemmenu-compare"), - icon, - commandListener: () => { - const items = getSelectedItems(); - if (items.length < 2) { - new ztoolkit.ProgressWindow("ChatPapers") - .createLine({ - text: getString("multi-need-two"), - type: "fail", - }) - .show(); - return; - } - openMultiChatDialog(items); - }, - }); } diff --git a/src/modules/ui/multiChatDialog.ts b/src/modules/ui/multiChatDialog.ts index e7b1c91..3cf59cd 100644 --- a/src/modules/ui/multiChatDialog.ts +++ b/src/modules/ui/multiChatDialog.ts @@ -198,15 +198,12 @@ export function openMultiChatDialog(items: Zotero.Item[]) { }); activeDialog = dialogHelper; - dialogHelper.open( - `ChatPapers · ${Math.min(selected.length, 8)} papers`, - { + dialogHelper.open("ChatPapers", { centerscreen: true, resizable: true, noDialogMode: true, fitContent: false, width: size.width, height: size.height, - }, - ); + }); } diff --git a/src/modules/ui/multiChatView.ts b/src/modules/ui/multiChatView.ts index c665471..4a8f290 100644 --- a/src/modules/ui/multiChatView.ts +++ b/src/modules/ui/multiChatView.ts @@ -1,15 +1,19 @@ import { Bot, + ChevronDown, Copy, + Download, GitCompare, Layers, ListTree, MessageSquareText, NotebookPen, + Plus, SendHorizontal, Sparkles, Square, User, + X, type IconNode, } from "lucide"; import { chatStream, getRuntimeConfig } from "../llm/client"; @@ -21,6 +25,13 @@ import { type MultiChatMode, } from "../pdf/multiContext"; import { createChildNote } from "../zotero/notes"; +import { + defaultCiteFormat, + formatCiteText, + listCiteFormatOptions, + saveCiteToFile, + type CiteFormatOption, +} from "../zotero/citeExport"; import { getString } from "../../utils/locale"; import { markdownToHtml } from "../../utils/markdown"; import { createLucideIcon } from "../../utils/icons"; @@ -29,6 +40,11 @@ import { isAbortError, type AbortHandle, } from "../../utils/abort"; +import { pickLibraryItems } from "./pickItems"; + +const MAX_PAPERS = 8; + +let rememberedCiteFormat = ""; export class MultiChatView { private body: HTMLElement; @@ -36,6 +52,7 @@ export class MultiChatView { private items: Zotero.Item[]; private messages: ChatMessage[] = []; private abort?: AbortHandle; + private citeFormats: CiteFormatOption[] = []; private messagesEl!: HTMLElement; private inputEl!: HTMLTextAreaElement; @@ -44,11 +61,22 @@ export class MultiChatView { private sendBtn!: HTMLButtonElement; private stopBtn!: HTMLButtonElement; private emptyEl!: HTMLElement; + private paperListEl!: HTMLElement; + private citeBarEl!: HTMLElement; + private citePickerEl!: HTMLElement; + private citeTriggerEl!: HTMLButtonElement; + private citeTriggerLabelEl!: HTMLElement; + private citeMenuEl!: HTMLElement; + private citeValue = ""; + private titleEl!: HTMLElement; + private compareBtn!: HTMLButtonElement; + private summaryEachBtn!: HTMLButtonElement; + private commonBtn!: HTMLButtonElement; constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) { this.doc = doc; this.body = body; - this.items = normalizeLibraryItems(items); + this.items = normalizeLibraryItems(items).slice(0, MAX_PAPERS); } async mount(): Promise { @@ -66,22 +94,13 @@ export class MultiChatView { size: 18, className: "chatpapers-brand-icon", }), - this.el( - "div", - "chatpapers-brand-text", - getString("multi-title"), - ), + 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} 篇)`; - + this.titleEl = this.el("div", "chatpapers-title"); header.append( brand, - this.el("div", "chatpapers-title", titles.join(" · ") + more), + this.titleEl, this.el( "div", "chatpapers-meta", @@ -89,20 +108,13 @@ export class MultiChatView { ), ); - 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.paperListEl = this.el("div", "chatpapers-paper-chips"); + this.citeBarEl = this.buildCiteBar(); this.messagesEl = this.el("div", "chatpapers-messages"); this.emptyEl = this.el("div", "chatpapers-empty"); this.emptyEl.append( - createLucideIcon(this.doc, GitCompare, { + createLucideIcon(this.doc, MessageSquareText, { size: 28, className: "chatpapers-empty-icon", }), @@ -115,19 +127,24 @@ export class MultiChatView { this.statusEl.append(this.statusTextEl); const toolbar = this.el("div", "chatpapers-toolbar"); + this.compareBtn = this.iconBtn(GitCompare, getString("multi-compare"), () => + this.runModeGuarded("compare", getString("multi-compare")), + ); + this.summaryEachBtn = this.iconBtn( + ListTree, + getString("multi-summary-each"), + () => this.runModeGuarded("summary-each", getString("multi-summary-each")), + ); + this.commonBtn = this.iconBtn(Layers, getString("multi-common"), () => + this.runModeGuarded("common-themes", getString("multi-common")), + ); toolbar.append( 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.compareBtn, + this.summaryEachBtn, + this.commonBtn, this.iconBtn(Sparkles, getString("chat-save-note"), () => this.saveLastNote(), ), @@ -163,14 +180,17 @@ export class MultiChatView { this.body.append( header, - paperList, + this.paperListEl, + this.citeBarEl, this.messagesEl, this.statusEl, toolbar, composer, ); + this.refreshPaperChrome(); this.renderMessages(); + void this.loadCiteFormats(); if (!cfg.provider || !cfg.baseUrl) { this.setStatus(getString("chat-need-config"), "warn"); } else { @@ -183,9 +203,186 @@ export class MultiChatView { destroy(): void { this.stop(); + try { + const onDocClick = (this.citePickerEl as any)?._onDocClick; + if (onDocClick) this.doc.removeEventListener("click", onDocClick, true); + } catch { + // ignore + } this.body.replaceChildren(); } + private buildCiteBar(): HTMLElement { + const bar = this.el("div", "chatpapers-cite-bar"); + const label = this.el( + "span", + "chatpapers-cite-label", + getString("cite-style"), + ); + + // Custom picker — native