Files
chatpapers/src/modules/ui/chatView.ts
T
2026-09-01 08:38:36 +08:00

483 lines
14 KiB
TypeScript

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<void> {
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");
}
}
}