first commit
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user