Files
chatpapers/src/modules/ui/chatView.ts
T
2026-07-20 14:40:17 +08:00

313 lines
9.6 KiB
TypeScript

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