import { chatStream, getRuntimeConfig } from "../llm/client"; import { LlmError, type ChatMessage } from "../llm/types"; import { getProviderPreset } from "../llm/providers"; import { buildChatMessages } from "../pdf/context"; import { clearSession, loadSession, saveSession } from "../storage/sessions"; import { createChildNote } from "../zotero/notes"; import { getString } from "../../utils/locale"; import { createAbortHandle, isAbortError, type AbortHandle, } from "../../utils/abort"; 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 sendBtn!: HTMLButtonElement; private stopBtn!: HTMLButtonElement; 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 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 || "无模型"}`), ); this.messagesEl = this.el("div", "chatpapers-messages"); this.statusEl = this.el("div", "chatpapers-status"); const toolbar = this.el("div", "chatpapers-toolbar"); const summarizeBtn = this.btn(getString("chat-summarize"), () => this.runSummary(), ); 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); 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.stopBtn.disabled = true; actions.append(this.sendBtn, this.stopBtn); 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, this.inputEl, actions); 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 btn(label: string, onClick: () => void): HTMLButtonElement { const b = this.doc.createElement("button"); b.type = "button"; b.className = "chatpapers-btn"; b.textContent = label; b.addEventListener("click", onClick); return b; } private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") { this.statusEl.textContent = text; this.statusEl.dataset.kind = kind; } private renderMessages() { this.messagesEl.replaceChildren(); for (const msg of this.messages) { if (msg.role === "system") continue; const bubble = this.el( "div", `chatpapers-bubble chatpapers-${msg.role}`, ); const role = this.el( "div", "chatpapers-role", msg.role === "user" ? "You" : "AI", ); const content = this.el("div", "chatpapers-content", msg.content); bubble.append(role, content); this.messagesEl.append(bubble); } this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } 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", ""), ); this.messagesEl.append(bubble); this.messagesEl.scrollTop = this.messagesEl.scrollHeight; return bubble.querySelector(".chatpapers-content") as HTMLElement; } 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(); } if (this.selectionText) { this.setStatus( `${getString("chat-selection-added")} (${this.selectionText.length} chars)`, "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.warning) { this.setStatus(built.warning, "warn"); } if (mode === "chat") { this.messages.push({ role: "user", content: userText }); } else { this.messages.push({ role: "user", content: userText }); } this.renderMessages(); const contentEl = this.appendAssistantPlaceholder(); let full = ""; await chatStream({ messages: built.messages, signal: this.abort.signal, onDelta: (delta) => { full += delta; contentEl.textContent = full; this.messagesEl.scrollTop = this.messagesEl.scrollHeight; }, }); this.messages.push({ role: "assistant", content: full || "(empty)" }); await this.persist(); this.setStatus(getString("chat-done"), "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"); } } }