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, type AbortHandle, } from "../../utils/abort"; import { isItemPaneSectionBody } from "./itemPaneSection"; export class ChatView { private body: HTMLElement; private doc: Document; private item: Zotero.Item; private messages: ChatMessage[] = []; private abort?: AbortHandle; private selectionText = ""; 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; this.body = body; this.item = item; } async mount(): Promise { this.body.replaceChildren(); this.body.classList.add("chatpapers-root"); const cfg = getRuntimeConfig(); const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined; const providerLabel = preset?.label || cfg.provider || "未配置"; const inItemPane = isItemPaneSectionBody(this.body); const header = this.el("div", "chatpapers-header"); if (!inItemPane) { 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"), ); header.append(brand); } 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(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"); 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 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.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, this.messagesEl, this.statusEl, toolbar, composer, ); const keys = this.sessionKeys(); this.messages = await loadSession(keys.itemKey, keys.attachmentKey); this.renderMessages(); if (!cfg.provider || !cfg.baseUrl) { this.setStatus(getString("chat-need-config"), "warn"); } } destroy(): void { this.stop(); this.body.replaceChildren(); } 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() ? this.item : undefined; return { itemKey: parent.key, attachmentKey: attachment?.key || "", }; } 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, options?: { preserveSelection?: boolean }, ): 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), ); 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.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(); 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, className: "chatpapers-role-icon", }), 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.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"); 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 captureSelection() { try { 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): ${preview}`, "info", ); } else { this.setStatus(getString("chat-selection-empty"), "warn"); } } catch (e) { ztoolkit.log(e); this.setStatus(getString("chat-selection-empty"), "warn"); } } private async persist() { const keys = this.sessionKeys(); await saveSession(keys.itemKey, keys.attachmentKey, this.messages); } 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.runChat(text, "chat"); } private async runSummary() { await this.runChat(getString("chat-summarize"), "summary"); } private async runChat(userText: string, mode: "chat" | "summary") { this.stop(); this.abort = createAbortHandle(); this.setBusy(true); this.setStatus(getString("chat-thinking"), "info"); try { const built = await buildChatMessages({ item: this.item, history: this.messages, userText, selection: this.selectionText, mode, }); this.selectionText = ""; if (!built.ready) { this.setStatus(built.warning || "无法读取 PDF 文本", "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)" }); await this.persist(); 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"); } 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 clearChat() { this.messages = []; const keys = this.sessionKeys(); await clearSession(keys.itemKey, keys.attachmentKey); this.renderMessages(); this.setStatus(getString("chat-cleared"), "info"); } 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.item.isRegularItem() ? this.item : this.item.parentItemID ? Zotero.Items.get(this.item.parentItemID) : this.item; const title = `ChatPapers · ${new Date().toLocaleString()}`; try { await createChildNote({ parentItem: parent, title, 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"); } } }