313 lines
9.6 KiB
TypeScript
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");
|
|
}
|
|
}
|
|
}
|