实现单篇和多篇论文的对比总结
This commit is contained in:
+234
-69
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user