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

This commit is contained in:
yehongyu
2026-07-20 15:22:40 +08:00
parent 50fd043a2d
commit c46d247665
23 changed files with 2710 additions and 268 deletions
+234 -69
View File
@@ -1,10 +1,28 @@
import {
Bot,
Check,
Copy,
Cpu,
Eraser,
Highlighter,
MessageSquareText,
NotebookPen,
SendHorizontal,
Sparkles,
Square,
User,
type IconNode,
} from "lucide";
import { chatStream, getRuntimeConfig } from "../llm/client";
import { LlmError, type ChatMessage } from "../llm/types";
import { getProviderPreset } from "../llm/providers";
import { buildChatMessages } from "../pdf/context";
import { getReaderSelectedText } from "../pdf/selection";
import { clearSession, loadSession, saveSession } from "../storage/sessions";
import { createChildNote } from "../zotero/notes";
import { getString } from "../../utils/locale";
import { markdownToHtml } from "../../utils/markdown";
import { createLucideIcon } from "../../utils/icons";
import {
createAbortHandle,
isAbortError,
@@ -22,8 +40,11 @@ export class ChatView {
private messagesEl!: HTMLElement;
private inputEl!: HTMLTextAreaElement;
private statusEl!: HTMLElement;
private statusIconEl!: HTMLElement;
private statusTextEl!: HTMLElement;
private sendBtn!: HTMLButtonElement;
private stopBtn!: HTMLButtonElement;
private emptyEl!: HTMLElement;
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
this.doc = doc;
@@ -40,36 +61,78 @@ export class ChatView {
const providerLabel = preset?.label || cfg.provider || "未配置";
const header = this.el("div", "chatpapers-header");
header.append(
this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"),
this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`),
const brand = this.el("div", "chatpapers-brand");
brand.append(
createLucideIcon(this.doc, MessageSquareText, {
size: 18,
className: "chatpapers-brand-icon",
}),
this.el("div", "chatpapers-brand-text", "ChatPapers"),
);
const titleRow = this.el("div", "chatpapers-title-row");
titleRow.append(
this.el(
"div",
"chatpapers-title",
this.item.getField("title") || "ChatPapers",
),
);
const meta = this.el("div", "chatpapers-meta");
meta.append(
createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }),
this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`),
);
header.append(brand, titleRow, meta);
this.messagesEl = this.el("div", "chatpapers-messages");
this.emptyEl = this.el("div", "chatpapers-empty");
this.emptyEl.append(
createLucideIcon(this.doc, Sparkles, {
size: 28,
className: "chatpapers-empty-icon",
}),
this.el("div", "chatpapers-empty-title", getString("chat-empty-title")),
this.el("div", "chatpapers-empty-desc", getString("chat-empty-desc")),
);
this.statusEl = this.el("div", "chatpapers-status");
this.statusIconEl = this.el("span", "chatpapers-status-icon");
this.statusTextEl = this.el("span", "chatpapers-status-text");
this.statusEl.append(this.statusIconEl, this.statusTextEl);
const toolbar = this.el("div", "chatpapers-toolbar");
const summarizeBtn = this.btn(getString("chat-summarize"), () =>
this.runSummary(),
toolbar.append(
this.iconBtn(Sparkles, getString("chat-summarize"), () => this.runSummary()),
this.iconBtn(
Highlighter,
getString("chat-add-selection"),
() => this.captureSelection(),
undefined,
{ preserveSelection: true },
),
this.iconBtn(Eraser, getString("chat-clear"), () => this.clearChat()),
this.iconBtn(NotebookPen, getString("chat-save-note"), () =>
this.saveLastNote(),
),
);
const addSelBtn = this.btn(getString("chat-add-selection"), () =>
this.captureSelection(),
);
const clearBtn = this.btn(getString("chat-clear"), () => this.clearChat());
const saveBtn = this.btn(getString("chat-save-note"), () => this.saveLastNote());
toolbar.append(summarizeBtn, addSelBtn, clearBtn, saveBtn);
const composer = this.el("div", "chatpapers-composer");
this.inputEl = this.doc.createElement("textarea");
this.inputEl.className = "chatpapers-input";
this.inputEl.rows = 3;
this.inputEl.placeholder = getString("chat-placeholder");
const actions = this.el("div", "chatpapers-actions");
this.sendBtn = this.btn(getString("chat-send"), () => this.send());
this.sendBtn.classList.add("chatpapers-primary");
this.stopBtn = this.btn(getString("chat-stop"), () => this.stop());
this.sendBtn = this.iconBtn(
SendHorizontal,
getString("chat-send"),
() => this.send(),
"chatpapers-primary",
);
this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => this.stop());
this.stopBtn.disabled = true;
actions.append(this.sendBtn, this.stopBtn);
composer.append(this.inputEl, actions);
this.inputEl.addEventListener("keydown", (ev) => {
const kev = ev as KeyboardEvent;
@@ -79,7 +142,13 @@ export class ChatView {
}
});
this.body.append(header, this.messagesEl, this.statusEl, toolbar, this.inputEl, actions);
this.body.append(
header,
this.messagesEl,
this.statusEl,
toolbar,
composer,
);
const keys = this.sessionKeys();
this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
@@ -95,15 +164,12 @@ export class ChatView {
}
private sessionKeys(): { itemKey: string; attachmentKey: string } {
const parent =
this.item.isRegularItem()
? this.item
: this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const attachment = this.item.isAttachment()
const parent = this.item.isRegularItem()
? this.item
: undefined;
: this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const attachment = this.item.isAttachment() ? this.item : undefined;
return {
itemKey: parent.key,
attachmentKey: attachment?.key || "",
@@ -117,67 +183,154 @@ export class ChatView {
return node;
}
private btn(label: string, onClick: () => void): HTMLButtonElement {
private iconBtn(
icon: IconNode,
label: string,
onClick: () => void,
extraClass?: string,
options?: { preserveSelection?: boolean },
): HTMLButtonElement {
const b = this.doc.createElement("button");
b.type = "button";
b.className = "chatpapers-btn";
b.textContent = label;
b.addEventListener("click", onClick);
b.className = extraClass
? `chatpapers-btn ${extraClass}`
: "chatpapers-btn";
b.title = label;
b.append(
createLucideIcon(this.doc, icon, { size: 15 }),
this.el("span", "chatpapers-btn-label", label),
);
if (options?.preserveSelection) {
// Prevent focus steal that clears PDF text selection
b.addEventListener("mousedown", (ev) => {
if ((ev as MouseEvent).button !== 0) return;
ev.preventDefault();
onClick();
});
} else {
b.addEventListener("click", onClick);
}
return b;
}
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") {
this.statusEl.textContent = text;
private setStatus(
text: string,
kind: "info" | "warn" | "error" | "" = "info",
) {
this.statusTextEl.textContent = text;
this.statusEl.dataset.kind = kind;
this.statusIconEl.replaceChildren();
if (!text) return;
const icon =
kind === "error" || kind === "warn"
? Sparkles
: Check;
this.statusIconEl.append(
createLucideIcon(this.doc, icon, { size: 13 }),
);
}
private renderMessages() {
this.messagesEl.replaceChildren();
for (const msg of this.messages) {
if (msg.role === "system") continue;
const visible = this.messages.filter((m) => m.role !== "system");
if (!visible.length) {
this.messagesEl.append(this.emptyEl);
return;
}
for (const msg of visible) {
const bubble = this.el(
"div",
`chatpapers-bubble chatpapers-${msg.role}`,
);
const role = this.el(
"div",
"chatpapers-role",
msg.role === "user" ? "You" : "AI",
const roleRow = this.el("div", "chatpapers-role");
const roleLeft = this.el("div", "chatpapers-role-left");
roleLeft.append(
createLucideIcon(this.doc, msg.role === "user" ? User : Bot, {
size: 13,
className: "chatpapers-role-icon",
}),
this.el("span", undefined, msg.role === "user" ? "You" : "AI"),
);
const content = this.el("div", "chatpapers-content", msg.content);
bubble.append(role, content);
roleRow.append(roleLeft);
if (msg.role === "assistant" && msg.content) {
const copyBtn = this.doc.createElement("button");
copyBtn.type = "button";
copyBtn.className = "chatpapers-copy";
copyBtn.title = getString("chat-copy");
copyBtn.append(
createLucideIcon(this.doc, Copy, { size: 12 }),
this.el("span", undefined, getString("chat-copy")),
);
copyBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.copyText(msg.content);
});
roleRow.append(copyBtn);
}
const content = this.el("div", "chatpapers-content");
this.fillContent(content, msg.content, msg.role === "assistant");
bubble.append(roleRow, content);
this.messagesEl.append(bubble);
}
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}
private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) {
if (asMarkdown) {
el.classList.add("chatpapers-md");
el.innerHTML = markdownToHtml(text || "");
} else {
el.classList.remove("chatpapers-md");
el.textContent = text || "";
}
}
private copyText(text: string) {
try {
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
this.setStatus(getString("chat-copied"), "info");
return;
} catch (e) {
ztoolkit.log("Clipboard helper failed", e);
}
try {
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
this.setStatus(getString("chat-copied"), "info");
} catch (e) {
ztoolkit.log(e);
this.setStatus(String(e), "error");
}
}
private appendAssistantPlaceholder(): HTMLElement {
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
bubble.append(
this.el("div", "chatpapers-role", "AI"),
this.el("div", "chatpapers-content", ""),
const roleRow = this.el("div", "chatpapers-role");
const roleLeft = this.el("div", "chatpapers-role-left");
roleLeft.append(
createLucideIcon(this.doc, Bot, { size: 13 }),
this.el("span", undefined, "AI"),
);
roleRow.append(roleLeft);
const content = this.el("div", "chatpapers-content chatpapers-md");
bubble.append(roleRow, content);
this.messagesEl.append(bubble);
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
return bubble.querySelector(".chatpapers-content") as HTMLElement;
return content;
}
private captureSelection() {
try {
const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID);
// @ts-expect-error internal reader APIs vary
const win = reader?._iframeWindow || reader?._internalReader?._primaryView?._iframe?.contentWindow;
const sel = win?.getSelection?.()?.toString?.() || "";
if (!sel.trim()) {
// try main window selection as fallback
const mainSel = this.doc.defaultView?.getSelection?.()?.toString?.() || "";
this.selectionText = mainSel.trim();
} else {
this.selectionText = sel.trim();
}
const text = getReaderSelectedText();
this.selectionText = text;
if (this.selectionText) {
const preview =
this.selectionText.length > 80
? `${this.selectionText.slice(0, 80)}`
: this.selectionText;
this.setStatus(
`${getString("chat-selection-added")} (${this.selectionText.length} chars)`,
`${getString("chat-selection-added")} (${this.selectionText.length} chars): ${preview}`,
"info",
);
} else {
@@ -233,32 +386,43 @@ export class ChatView {
});
this.selectionText = "";
if (built.warning) {
this.setStatus(built.warning, "warn");
if (!built.ready) {
this.setStatus(built.warning || "无法读取 PDF 文本", "error");
return;
}
if (mode === "chat") {
this.messages.push({ role: "user", content: userText });
} else {
this.messages.push({ role: "user", content: userText });
if (built.warning) {
this.setStatus(built.warning, "info");
}
this.messages.push({ role: "user", content: userText });
this.renderMessages();
const contentEl = this.appendAssistantPlaceholder();
let full = "";
let lastRender = 0;
await chatStream({
messages: built.messages,
signal: this.abort.signal,
onDelta: (delta) => {
full += delta;
contentEl.textContent = full;
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
const now = Date.now();
if (now - lastRender > 80) {
lastRender = now;
this.fillContent(contentEl, full, true);
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}
},
});
this.fillContent(contentEl, full || "(empty)", true);
this.messages.push({ role: "assistant", content: full || "(empty)" });
await this.persist();
this.setStatus(getString("chat-done"), "info");
const chars = built.extract.text?.length || 0;
this.setStatus(
`${getString("chat-done")} · 已基于 ${chars} 字符论文文本`,
"info",
);
} catch (e) {
if (isAbortError(e)) {
this.setStatus(getString("chat-stopped"), "warn");
@@ -283,17 +447,18 @@ export class ChatView {
}
private async saveLastNote() {
const last = [...this.messages].reverse().find((m) => m.role === "assistant");
const last = [...this.messages]
.reverse()
.find((m) => m.role === "assistant");
if (!last) {
this.setStatus(getString("chat-no-reply"), "warn");
return;
}
const parent =
this.item.isRegularItem()
? this.item
: this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const parent = this.item.isRegularItem()
? this.item
: this.item.parentItemID
? Zotero.Items.get(this.item.parentItemID)
: this.item;
const title = `ChatPapers · ${new Date().toLocaleString()}`;
try {
await createChildNote({