483 lines
14 KiB
TypeScript
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");
|
|
}
|
|
}
|
|
}
|