first commit

This commit is contained in:
yehongyu
2026-07-20 14:40:17 +08:00
commit 50fd043a2d
55 changed files with 7786 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { config } from "../package.json";
import hooks from "./hooks";
import { createZToolkit } from "./utils/ztoolkit";
class Addon {
public data: {
alive: boolean;
config: typeof config;
env: "development" | "production";
initialized?: boolean;
ztoolkit: ZToolkit;
locale?: {
current: any;
};
prefs?: {
window: Window;
};
};
public hooks: typeof hooks;
public api: object;
constructor() {
this.data = {
alive: true,
config,
env: __env__,
initialized: false,
ztoolkit: createZToolkit(),
};
this.hooks = hooks;
this.api = {};
}
}
export default Addon;
+58
View File
@@ -0,0 +1,58 @@
import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs";
async function onStartup() {
await Promise.all([
Zotero.initializationPromise,
Zotero.unlockPromise,
Zotero.uiReadyPromise,
]);
initLocale();
registerPrefs();
registerChatPane();
await Promise.all(
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
);
addon.data.initialized = true;
}
async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
addon.data.ztoolkit = createZToolkit();
win.MozXULElement.insertFTLIfNeeded(
`${addon.data.config.addonRef}-mainWindow.ftl`,
);
}
async function onMainWindowUnload(_win: Window): Promise<void> {
ztoolkit.unregisterAll();
}
function onShutdown(): void {
ztoolkit.unregisterAll();
addon.data.alive = false;
// @ts-expect-error - Plugin instance is not typed
delete Zotero[addon.data.config.addonInstance];
}
async function onPrefsEvent(type: string, data: { [key: string]: any }) {
switch (type) {
case "load":
registerPrefsScripts(data.window);
break;
default:
return;
}
}
export default {
onStartup,
onShutdown,
onMainWindowLoad,
onMainWindowUnload,
onPrefsEvent,
};
+25
View File
@@ -0,0 +1,25 @@
import { BasicTool } from "zotero-plugin-toolkit";
import Addon from "./addon";
import { config } from "../package.json";
const basicTool = new BasicTool();
// @ts-expect-error - Plugin instance is not typed
if (!basicTool.getGlobal("Zotero")[config.addonInstance]) {
_globalThis.addon = new Addon();
defineGlobal("ztoolkit", () => {
return _globalThis.addon.data.ztoolkit;
});
// @ts-expect-error - Plugin instance is not typed
Zotero[config.addonInstance] = addon;
}
function defineGlobal(name: Parameters<BasicTool["getGlobal"]>[0]): void;
function defineGlobal(name: string, getter: () => any): void;
function defineGlobal(name: string, getter?: () => any) {
Object.defineProperty(_globalThis, name, {
get() {
return getter ? getter() : basicTool.getGlobal(name);
},
});
}
+238
View File
@@ -0,0 +1,238 @@
import { getPref } from "../../utils/prefs";
import type { AbortSignalLike } from "../../utils/abort";
import { getProviderPreset, isLocalProvider } from "./providers";
import type { ChatMessage, LlmRuntimeConfig } from "./types";
import { LlmError } from "./types";
import { joinUrl, normalizeBaseUrl } from "./url";
export function getRuntimeConfig(): LlmRuntimeConfig {
const provider = (getPref("provider") || "") as LlmRuntimeConfig["provider"];
const preset = provider ? getProviderPreset(provider) : undefined;
return {
provider,
baseUrl: getPref("apiBaseUrl") || preset?.baseUrl || "",
apiKey: getPref("apiKey") || "",
model: getPref("model") || preset?.defaultModel || "",
temperature: Number(getPref("temperature") ?? 0.3),
maxTokens: Number(getPref("maxTokens") ?? 2048),
timeoutMs: Number(getPref("timeoutMs") ?? 120000),
openrouterReferer: getPref("openrouterReferer") || "",
openrouterTitle: getPref("openrouterTitle") || "ChatPapers",
};
}
export function assertConfig(cfg: LlmRuntimeConfig): void {
if (!cfg.baseUrl) {
throw new LlmError(
"config",
"请先在设置中选择 Provider 并配置 Base URL。",
);
}
if (!cfg.model) {
throw new LlmError("config", "请先配置模型名称(Model)。");
}
const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined;
if (preset?.needsKey && !cfg.apiKey) {
throw new LlmError("config", "当前 Provider 需要填写 API Key。");
}
}
function resolveFetch(): typeof fetch {
const candidates = [
(globalThis as any).fetch,
typeof fetch !== "undefined" ? fetch : undefined,
];
try {
candidates.push(ztoolkit.getGlobal("fetch" as any));
} catch {
// ignore
}
try {
const win = Zotero.getMainWindow?.();
if (win) candidates.push((win as any).fetch?.bind(win));
} catch {
// ignore
}
for (const fn of candidates) {
if (typeof fn === "function") return fn as typeof fetch;
}
throw new LlmError("network", "当前环境不支持 fetch,无法请求 AI 服务。");
}
function abortError(): Error {
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignalLike) {
if (signal?.aborted) throw abortError();
}
function buildHeaders(cfg: LlmRuntimeConfig): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (cfg.apiKey) {
headers.Authorization = `Bearer ${cfg.apiKey}`;
}
if (cfg.provider === "openrouter") {
if (cfg.openrouterReferer) {
headers["HTTP-Referer"] = cfg.openrouterReferer;
}
if (cfg.openrouterTitle) {
headers["X-Title"] = cfg.openrouterTitle;
}
}
return headers;
}
function mapHttpError(status: number, body: string, cfg: LlmRuntimeConfig): LlmError {
if (status === 401 || status === 403) {
return new LlmError("auth", `认证失败 (${status}):请检查 API Key。`, status);
}
if (status === 429) {
return new LlmError("rate_limit", `触发限流 (${status}),请稍后重试。`, status);
}
if (status >= 500 && isLocalProvider(cfg.provider)) {
return new LlmError(
"local_down",
`本地服务异常 (${status})。请确认 Ollama / LM Studio 已启动。`,
status,
);
}
return new LlmError(
"provider",
`请求失败 (${status})${body.slice(0, 300) || "无响应内容"}`,
status,
);
}
export async function listModels(
signal?: AbortSignalLike,
): Promise<string[]> {
const cfg = getRuntimeConfig();
assertConfig({ ...cfg, model: cfg.model || "placeholder" });
const url = joinUrl(cfg.baseUrl, "/models");
const doFetch = resolveFetch();
try {
const res = await doFetch(url, {
method: "GET",
headers: buildHeaders(cfg),
signal: signal as AbortSignal | undefined,
});
if (!res.ok) {
const text = await res.text();
throw mapHttpError(res.status, text, cfg);
}
const data = (await res.json()) as {
data?: Array<{ id?: string }>;
};
return (data.data || [])
.map((m) => m.id || "")
.filter(Boolean)
.sort();
} catch (e) {
if (e instanceof LlmError) throw e;
if (signal?.aborted) throw abortError();
if (isLocalProvider(cfg.provider)) {
throw new LlmError(
"local_down",
`无法连接本地服务 (${normalizeBaseUrl(cfg.baseUrl)})。请先启动 Ollama / LM Studio。`,
);
}
throw new LlmError("network", `网络错误:${String(e)}`);
}
}
export async function chatStream(options: {
messages: ChatMessage[];
onDelta: (text: string) => void;
signal?: AbortSignalLike;
}): Promise<string> {
const cfg = getRuntimeConfig();
assertConfig(cfg);
throwIfAborted(options.signal);
const url = joinUrl(cfg.baseUrl, "/chat/completions");
const body = {
model: cfg.model,
messages: options.messages,
stream: true,
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
};
const doFetch = resolveFetch();
let res: Response;
try {
res = await doFetch(url, {
method: "POST",
headers: buildHeaders(cfg),
body: JSON.stringify(body),
signal: options.signal as AbortSignal | undefined,
});
} catch (e) {
if (options.signal?.aborted) throw abortError();
if (isLocalProvider(cfg.provider)) {
throw new LlmError(
"local_down",
`无法连接本地服务 (${normalizeBaseUrl(cfg.baseUrl)})。请先启动 Ollama / LM Studio。`,
);
}
throw new LlmError("network", `网络错误:${String(e)}`);
}
throwIfAborted(options.signal);
if (!res.ok) {
const text = await res.text();
throw mapHttpError(res.status, text, cfg);
}
if (!res.body) {
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content || "";
if (content) options.onDelta(content);
return content;
}
const reader = res.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
const decoder = new TextDecoder();
let buffer = "";
let full = "";
while (true) {
throwIfAborted(options.signal);
const result = await reader.read();
const done = result.done;
const value = result.value;
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const json = JSON.parse(payload) as {
choices?: Array<{ delta?: { content?: string } }>;
};
const delta = json.choices?.[0]?.delta?.content || "";
if (delta) {
full += delta;
options.onDelta(delta);
}
} catch {
// ignore malformed chunks
}
}
}
return full;
}
+29
View File
@@ -0,0 +1,29 @@
import { getPref } from "../../utils/prefs";
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题。
要求:
1. 回答准确、简洁,优先使用论文原文依据。
2. 若信息不足,请明确说明,不要编造。
3. 尽量标注页码或引用短片段(若上下文含页分隔)。
4. 使用用户偏好的语言回答。`;
const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含:
## 一句话概括
## 研究问题 / 动机
## 方法
## 主要结果
## 贡献与创新
## 局限与可追问点
## 关键术语(可选)`;
export function getSystemPrompt(): string {
const custom = (getPref("systemPrompt") || "").trim();
const lang = getPref("answerLanguage") || "zh-CN";
const base = custom || DEFAULT_SYSTEM;
return `${base}\n\n回答语言偏好:${lang}`;
}
export function getSummaryPrompt(): string {
const custom = (getPref("summaryPrompt") || "").trim();
return custom || DEFAULT_SUMMARY;
}
+71
View File
@@ -0,0 +1,71 @@
import type { ProviderId, ProviderPreset } from "./types";
export const PROVIDER_PRESETS: Record<ProviderId, ProviderPreset> = {
ollama: {
id: "ollama",
label: "Ollama",
baseUrl: "http://127.0.0.1:11434/v1",
needsKey: false,
defaultModel: "qwen2.5",
},
lmstudio: {
id: "lmstudio",
label: "LM Studio",
baseUrl: "http://127.0.0.1:1234/v1",
needsKey: false,
defaultModel: "local-model",
},
openrouter: {
id: "openrouter",
label: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
needsKey: true,
defaultModel: "openai/gpt-4o-mini",
},
openai: {
id: "openai",
label: "OpenAI",
baseUrl: "https://api.openai.com/v1",
needsKey: true,
defaultModel: "gpt-4o-mini",
},
deepseek: {
id: "deepseek",
label: "DeepSeek",
baseUrl: "https://api.deepseek.com/v1",
needsKey: true,
defaultModel: "deepseek-chat",
},
siliconflow: {
id: "siliconflow",
label: "SiliconFlow",
baseUrl: "https://api.siliconflow.cn/v1",
needsKey: true,
defaultModel: "deepseek-ai/DeepSeek-V3",
},
custom: {
id: "custom",
label: "Custom",
baseUrl: "",
needsKey: true,
defaultModel: "",
},
};
export const PROVIDER_ORDER: ProviderId[] = [
"ollama",
"lmstudio",
"openrouter",
"openai",
"deepseek",
"siliconflow",
"custom",
];
export function getProviderPreset(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS[id as ProviderId];
}
export function isLocalProvider(id: string): boolean {
return id === "ollama" || id === "lmstudio";
}
+54
View File
@@ -0,0 +1,54 @@
export type ProviderId =
| "ollama"
| "lmstudio"
| "openrouter"
| "openai"
| "deepseek"
| "siliconflow"
| "custom";
export interface ProviderPreset {
id: ProviderId;
label: string;
baseUrl: string;
needsKey: boolean;
defaultModel: string;
extraHeaders?: Record<string, string>;
}
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LlmRuntimeConfig {
provider: ProviderId | "";
baseUrl: string;
apiKey: string;
model: string;
temperature: number;
maxTokens: number;
timeoutMs: number;
openrouterReferer: string;
openrouterTitle: string;
}
export type LlmErrorCode =
| "auth"
| "rate_limit"
| "network"
| "local_down"
| "provider"
| "config";
export class LlmError extends Error {
code: LlmErrorCode;
status?: number;
constructor(code: LlmErrorCode, message: string, status?: number) {
super(message);
this.name = "LlmError";
this.code = code;
this.status = status;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Normalize OpenAI-compatible base URL.
* Accepts both `http://host:port` and `http://host:port/v1`.
*/
export function normalizeBaseUrl(input: string): string {
let url = (input || "").trim().replace(/\/+$/, "");
if (!url) return "";
if (!/\/v\d+$/i.test(url)) {
url = `${url}/v1`;
}
return url;
}
export function joinUrl(baseUrl: string, path: string): string {
const base = normalizeBaseUrl(baseUrl).replace(/\/+$/, "");
const p = path.startsWith("/") ? path : `/${path}`;
// Avoid /v1/v1/...
if (base.endsWith("/v1") && p.startsWith("/v1/")) {
return `${base}${p.slice(3)}`;
}
return `${base}${p}`;
}
+77
View File
@@ -0,0 +1,77 @@
import type { ChatMessage } from "../llm/types";
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
import {
buildMetadataBlock,
extractPdfContext,
type ExtractResult,
} from "./extractor";
export interface BuiltContext {
messages: ChatMessage[];
extract: ExtractResult;
warning?: string;
}
export async function buildChatMessages(options: {
item: Zotero.Item;
history: ChatMessage[];
userText: string;
selection?: string;
mode?: "chat" | "summary";
}): Promise<BuiltContext> {
const extract = await extractPdfContext(options.item);
const warnings: string[] = [];
if (!extract.text) {
warnings.push(
"未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。",
);
} else if (extract.truncated) {
warnings.push("论文较长,已按设置截断上下文。");
}
const meta = extract.parentItem
? buildMetadataBlock(extract.parentItem)
: "";
const systemParts = [getSystemPrompt()];
if (meta) {
systemParts.push(`论文元数据:\n${meta}`);
}
if (extract.text) {
systemParts.push(
`论文正文(页之间可能以换页符分隔):\n${extract.text}`,
);
}
const messages: ChatMessage[] = [
{ role: "system", content: systemParts.join("\n\n") },
];
// Keep recent history (exclude old system)
const history = options.history
.filter((m) => m.role !== "system")
.slice(-20);
messages.push(...history);
if (options.mode === "summary") {
const summaryAsk = getSummaryPrompt();
messages.push({
role: "user",
content: summaryAsk,
});
} else {
let content = options.userText.trim();
if (options.selection?.trim()) {
content =
`【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`;
}
messages.push({ role: "user", content });
}
return {
messages,
extract,
warning: warnings.length ? warnings.join(" ") : undefined,
};
}
+136
View File
@@ -0,0 +1,136 @@
import { getPref } from "../../utils/prefs";
export interface ExtractResult {
text: string;
truncated: boolean;
pageCount?: number;
source: "pdfworker" | "fulltext" | "empty";
attachment?: Zotero.Item;
parentItem?: Zotero.Item;
}
function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
if (item.isAttachment() && item.attachmentContentType === "application/pdf") {
return item;
}
if (item.isRegularItem()) {
const attachments = item.getAttachments().map((id) => Zotero.Items.get(id));
return attachments.find(
(att) => att?.isAttachment() && att.attachmentContentType === "application/pdf",
);
}
// Child attachment of a parent
if (item.isAttachment() && item.parentItemID) {
const parent = Zotero.Items.get(item.parentItemID);
return findPdfAttachment(parent);
}
return undefined;
}
function getParentItem(item: Zotero.Item): Zotero.Item {
if (item.isRegularItem()) return item;
if (item.parentItemID) {
return Zotero.Items.get(item.parentItemID) || item;
}
return item;
}
async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
const worker = (Zotero as any).PDFWorker;
if (!worker?.getFullText) {
throw new Error("PDFWorker unavailable");
}
const result = await worker.getFullText(attachment.id, null, true);
return (result?.text || "").trim();
}
async function extractViaFulltext(attachment: Zotero.Item): Promise<string> {
try {
const text = (attachment as any).attachmentText;
if (typeof text === "string" && text.trim()) return text.trim();
} catch {
// ignore
}
return "";
}
export function truncateText(
text: string,
maxChars: number,
): { text: string; truncated: boolean } {
if (text.length <= maxChars) {
return { text, truncated: false };
}
const head = Math.floor(maxChars * 0.7);
const tail = maxChars - head - 80;
const sliced =
text.slice(0, head) +
"\n\n[... 中间内容已截断 ...]\n\n" +
text.slice(-Math.max(tail, 0));
return { text: sliced, truncated: true };
}
export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
const attachment = findPdfAttachment(item);
const parentItem = getParentItem(item);
if (!attachment) {
return {
text: "",
truncated: false,
source: "empty",
parentItem,
};
}
let raw = "";
let source: ExtractResult["source"] = "empty";
try {
raw = await extractViaPdfWorker(attachment);
if (raw) source = "pdfworker";
} catch (e) {
ztoolkit.log("PDFWorker extract failed", e);
}
if (!raw) {
try {
raw = await extractViaFulltext(attachment);
if (raw) source = "fulltext";
} catch (e) {
ztoolkit.log("Fulltext extract failed", e);
}
}
const maxChars = Number(getPref("maxContextChars") ?? 80000);
const { text, truncated } = truncateText(raw, maxChars);
const pageCount = raw ? raw.split("\f").length : undefined;
return {
text,
truncated,
pageCount,
source,
attachment,
parentItem,
};
}
export function buildMetadataBlock(item: Zotero.Item): string {
if (!getPref("sendMetadata")) return "";
const title = item.getField("title") || "";
const creators = item.getCreators()
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
.filter(Boolean)
.join(", ");
const year = (item.getField("date") || "").slice(0, 4);
const doi = item.getField("DOI") || "";
const abstract = item.getField("abstractNote") || "";
return [
`标题:${title}`,
creators ? `作者:${creators}` : "",
year ? `年份:${year}` : "",
doi ? `DOI${doi}` : "",
abstract ? `摘要:${abstract}` : "",
]
.filter(Boolean)
.join("\n");
}
+71
View File
@@ -0,0 +1,71 @@
import type { ChatMessage } from "../llm/types";
function sessionDir(): string {
const profile = (Zotero as any).Profile?.dir || (Zotero as any).getProfileDirectory?.()?.path;
if (!profile) {
throw new Error("Cannot resolve Zotero profile directory");
}
return PathUtils.join(profile, "chatpapers", "sessions");
}
function sessionPath(itemKey: string, attachmentKey: string): string {
const safe = `${itemKey}-${attachmentKey || "none"}`.replace(
/[^a-zA-Z0-9_-]/g,
"_",
);
return PathUtils.join(sessionDir(), `${safe}.json`);
}
export async function loadSession(
itemKey: string,
attachmentKey: string,
): Promise<ChatMessage[]> {
try {
const path = sessionPath(itemKey, attachmentKey);
const exists = await IOUtils.exists(path);
if (!exists) return [];
const raw = await IOUtils.readUTF8(path);
const data = JSON.parse(raw) as { messages?: ChatMessage[] };
return (data.messages || []).filter((m) => m.role !== "system");
} catch (e) {
ztoolkit.log("loadSession failed", e);
return [];
}
}
export async function saveSession(
itemKey: string,
attachmentKey: string,
messages: ChatMessage[],
): Promise<void> {
try {
const dir = sessionDir();
await IOUtils.makeDirectory(dir, { createAncestors: true, ignoreExisting: true });
const path = sessionPath(itemKey, attachmentKey);
const payload = JSON.stringify(
{
updatedAt: Date.now(),
messages: messages.filter((m) => m.role !== "system").slice(-50),
},
null,
2,
);
await IOUtils.writeUTF8(path, payload);
} catch (e) {
ztoolkit.log("saveSession failed", e);
}
}
export async function clearSession(
itemKey: string,
attachmentKey: string,
): Promise<void> {
try {
const path = sessionPath(itemKey, attachmentKey);
if (await IOUtils.exists(path)) {
await IOUtils.remove(path);
}
} catch (e) {
ztoolkit.log("clearSession failed", e);
}
}
+312
View File
@@ -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");
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import { getPref, setPref } from "../../utils/prefs";
import { listModels } from "../llm/client";
import { LlmError } from "../llm/types";
import {
PROVIDER_ORDER,
PROVIDER_PRESETS,
getProviderPreset,
isLocalProvider,
} from "../llm/providers";
import { getString } from "../../utils/locale";
export function registerPrefsScripts(win: Window) {
const doc = win.document;
const providerSelect = doc.getElementById(
"chatpapers-pref-provider",
) as HTMLSelectElement | null;
const baseUrlInput = doc.getElementById(
"chatpapers-pref-apiBaseUrl",
) as HTMLInputElement | null;
const modelInput = doc.getElementById(
"chatpapers-pref-model",
) as HTMLInputElement | null;
const resetBtn = doc.getElementById("chatpapers-pref-reset");
const refreshBtn = doc.getElementById("chatpapers-pref-refresh-models");
const hint = doc.getElementById("chatpapers-pref-hint");
if (providerSelect && providerSelect.options.length === 0) {
for (const id of PROVIDER_ORDER) {
const opt = doc.createElement("option");
opt.value = id;
opt.textContent = PROVIDER_PRESETS[id].label;
providerSelect.appendChild(opt);
}
const current = getPref("provider") || "";
if (current) providerSelect.value = current;
}
const updateHint = () => {
if (!hint) return;
const id = (providerSelect?.value || getPref("provider") || "") as string;
if (isLocalProvider(id)) {
hint.textContent = getString("prefs-hint-local");
} else if (id === "openrouter") {
hint.textContent = getString("prefs-hint-openrouter");
} else if (id) {
hint.textContent = getString("prefs-hint-cloud");
} else {
hint.textContent = getString("prefs-hint-empty");
}
};
providerSelect?.addEventListener("change", () => {
const id = providerSelect.value;
setPref("provider", id);
const preset = getProviderPreset(id);
if (preset) {
// Always apply preset defaults on switch; user can edit after
setPref("apiBaseUrl", preset.baseUrl);
setPref("model", preset.defaultModel);
if (baseUrlInput) baseUrlInput.value = preset.baseUrl;
if (modelInput) modelInput.value = preset.defaultModel;
}
updateHint();
});
resetBtn?.addEventListener("command", () => {
const id = providerSelect?.value || getPref("provider");
const preset = getProviderPreset(id);
if (!preset) return;
setPref("apiBaseUrl", preset.baseUrl);
setPref("model", preset.defaultModel);
if (baseUrlInput) baseUrlInput.value = preset.baseUrl;
if (modelInput) modelInput.value = preset.defaultModel;
});
refreshBtn?.addEventListener("command", async () => {
try {
refreshBtn.setAttribute("disabled", "true");
const models = await listModels();
if (!models.length) {
win.alert(getString("prefs-models-empty"));
return;
}
const picked = models[0];
const choice = win.prompt(
`${getString("prefs-models-pick")}\n\n${models.slice(0, 30).join("\n")}`,
getPref("model") || picked,
);
if (choice) {
setPref("model", choice);
if (modelInput) modelInput.value = choice;
}
} catch (e) {
const msg = e instanceof LlmError ? e.message : String(e);
win.alert(msg);
} finally {
refreshBtn.removeAttribute("disabled");
}
});
updateHint();
}
+76
View File
@@ -0,0 +1,76 @@
import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale";
import { ChatView } from "./chatView";
const views = new WeakMap<HTMLElement, ChatView>();
function iconURL(file: string) {
return `chrome://${config.addonRef}/content/icons/${file}`;
}
export function registerChatPane() {
Zotero.ItemPaneManager.registerSection({
paneID: "chatpapers-chat",
pluginID: config.addonID,
header: {
l10nID: getLocaleID("item-section-chat-head"),
icon: iconURL("chat.svg"),
},
sidenav: {
l10nID: getLocaleID("item-section-chat-sidenav"),
icon: iconURL("chat.svg"),
},
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureStyles(doc);
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
views.delete(body);
},
onItemChange: ({ item, setEnabled, tabType }) => {
// Enable in reader and library item pane when there is an item
setEnabled(Boolean(item));
void tabType;
return true;
},
onRender: ({ body }) => {
body.replaceChildren();
const doc = body.ownerDocument;
if (!doc) return;
const loading = doc.createElement("div");
loading.className = "chatpapers-loading";
loading.textContent = getString("chat-loading");
body.append(loading);
},
onAsyncRender: async ({ body, item }) => {
if (!item) return;
const doc = body.ownerDocument;
if (!doc) return;
views.get(body)?.destroy();
const view = new ChatView(doc, body, item);
views.set(body, view);
await view.mount();
},
});
}
export function registerPrefs() {
Zotero.PreferencePanes.register({
pluginID: config.addonID,
src: rootURI + "content/preferences.xhtml",
label: getString("prefs-title"),
image: `chrome://${config.addonRef}/content/icons/favicon.png`,
});
}
function ensureStyles(doc: Document) {
const id = "chatpapers-styles";
if (doc.getElementById(id)) return;
const link = doc.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.type = "text/css";
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
doc.documentElement?.appendChild(link);
}
+100
View File
@@ -0,0 +1,100 @@
export async function createChildNote(options: {
parentItem: Zotero.Item;
title: string;
bodyMarkdown: string;
}): Promise<Zotero.Item> {
const note = new Zotero.Item("note");
note.libraryID = options.parentItem.libraryID;
note.parentID = options.parentItem.id;
const html = markdownToSimpleHtml(options.title, options.bodyMarkdown);
note.setNote(html);
await note.saveTx();
return note;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Minimal markdown → HTML for Zotero notes */
export function markdownToSimpleHtml(title: string, md: string): string {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const parts: string[] = [
`<h1>${escapeHtml(title)}</h1>`,
`<p><i>Generated by ChatPapers · ${new Date().toLocaleString()}</i></p>`,
];
let inList = false;
let inCode = false;
const codeBuf: string[] = [];
const closeList = () => {
if (inList) {
parts.push("</ul>");
inList = false;
}
};
for (const line of lines) {
if (line.startsWith("```")) {
if (inCode) {
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
codeBuf.length = 0;
inCode = false;
} else {
closeList();
inCode = true;
}
continue;
}
if (inCode) {
codeBuf.push(line);
continue;
}
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
if (heading) {
closeList();
const level = heading[1].length;
parts.push(`<h${level + 1}>${escapeHtml(heading[2])}</h${level + 1}>`);
continue;
}
const li = /^[-*]\s+(.*)$/.exec(line);
if (li) {
if (!inList) {
parts.push("<ul>");
inList = true;
}
parts.push(`<li>${inlineFormat(li[1])}</li>`);
continue;
}
if (!line.trim()) {
closeList();
continue;
}
closeList();
parts.push(`<p>${inlineFormat(line)}</p>`);
}
closeList();
if (inCode) {
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
}
return parts.join("\n");
}
function inlineFormat(text: string): string {
let s = escapeHtml(text);
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
return s;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Zotero plugin sandbox may not expose AbortController as a free global.
* Resolve from chrome globals, or fall back to a minimal stub.
*/
export type AbortHandle = {
signal: AbortSignalLike;
abort: () => void;
};
export type AbortSignalLike = {
aborted: boolean;
reason?: unknown;
addEventListener?: (
type: "abort",
listener: () => void,
options?: { once?: boolean },
) => void;
removeEventListener?: (type: "abort", listener: () => void) => void;
};
function resolveNativeAbortController(): typeof AbortController | undefined {
const candidates = [
(globalThis as any).AbortController,
typeof AbortController !== "undefined" ? AbortController : undefined,
];
try {
candidates.push(ztoolkit.getGlobal("AbortController" as any));
} catch {
// ignore
}
try {
const win = Zotero.getMainWindow?.();
if (win) candidates.push((win as any).AbortController);
} catch {
// ignore
}
for (const Ctor of candidates) {
if (typeof Ctor === "function") return Ctor;
}
return undefined;
}
class StubAbortSignal implements AbortSignalLike {
aborted = false;
reason: unknown;
private listeners: Array<() => void> = [];
addEventListener(
type: "abort",
listener: () => void,
_options?: { once?: boolean },
) {
if (type === "abort") this.listeners.push(listener);
}
removeEventListener(type: "abort", listener: () => void) {
if (type !== "abort") return;
this.listeners = this.listeners.filter((l) => l !== listener);
}
_abort(reason?: unknown) {
if (this.aborted) return;
this.aborted = true;
this.reason = reason;
for (const l of [...this.listeners]) l();
}
}
class StubAbortController {
signal = new StubAbortSignal();
abort(reason?: unknown) {
const err = new Error("Aborted");
err.name = "AbortError";
this.signal._abort(reason ?? err);
}
}
export function createAbortHandle(): AbortHandle {
const Native = resolveNativeAbortController();
if (Native) {
const ctrl = new Native();
return {
signal: ctrl.signal as AbortSignalLike,
abort: () => ctrl.abort(),
};
}
const stub = new StubAbortController();
return {
signal: stub.signal,
abort: () => stub.abort(),
};
}
export function isAbortError(e: unknown): boolean {
if (!e || typeof e !== "object") return false;
const err = e as { name?: string; message?: string };
return (
err.name === "AbortError" ||
String(err.message || "").toLowerCase().includes("abort")
);
}
+96
View File
@@ -0,0 +1,96 @@
import { config } from "../../package.json";
import { FluentMessageId } from "../../typings/i10n";
export { initLocale, getString, getLocaleID };
/**
* Initialize locale data
*/
function initLocale() {
const l10n = new (
typeof Localization === "undefined"
? ztoolkit.getGlobal("Localization")
: Localization
)([`${config.addonRef}-addon.ftl`], true);
addon.data.locale = {
current: l10n,
};
}
/**
* Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl
* @param localString ftl key
* @param options.branch branch name
* @param options.args args
* @example
* ```ftl
* # addon.ftl
* addon-static-example = This is default branch!
* .branch-example = This is a branch under addon-static-example!
* addon-dynamic-example =
{ $count ->
[one] I have { $count } apple
*[other] I have { $count } apples
}
* ```
* ```js
* getString("addon-static-example"); // This is default branch!
* getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example!
* getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple
* getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples
* ```
*/
function getString(localString: FluentMessageId): string;
function getString(localString: FluentMessageId, branch: string): string;
function getString(
localeString: FluentMessageId,
options: { branch?: string | undefined; args?: Record<string, unknown> },
): string;
function getString(...inputs: any[]) {
if (inputs.length === 1) {
return _getString(inputs[0]);
} else if (inputs.length === 2) {
if (typeof inputs[1] === "string") {
return _getString(inputs[0], { branch: inputs[1] });
} else {
return _getString(inputs[0], inputs[1]);
}
} else {
throw new Error("Invalid arguments");
}
}
interface Pattern {
value: string | null;
attributes: Array<{
name: string;
value: string;
}> | null;
}
function _getString(
localeString: FluentMessageId,
options: { branch?: string | undefined; args?: Record<string, unknown> } = {},
): string {
const localStringWithPrefix = `${config.addonRef}-${localeString}`;
const { branch, args } = options;
const pattern = addon.data.locale?.current.formatMessagesSync([
{ id: localStringWithPrefix, args },
])[0] as Pattern;
if (!pattern) {
return localStringWithPrefix;
}
if (branch && pattern.attributes) {
return (
pattern.attributes.find((attr) => attr.name === branch)?.value ||
localStringWithPrefix
);
} else {
return pattern.value || localStringWithPrefix;
}
}
function getLocaleID(id: FluentMessageId) {
return `${config.addonRef}-${id}`;
}
+36
View File
@@ -0,0 +1,36 @@
import { config } from "../../package.json";
type PluginPrefsMap = _ZoteroTypes.Prefs["PluginPrefsMap"];
const PREFS_PREFIX = config.prefsPrefix;
/**
* Get preference value.
* Wrapper of `Zotero.Prefs.get`.
* @param key
*/
export function getPref<K extends keyof PluginPrefsMap>(key: K) {
return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K];
}
/**
* Set preference value.
* Wrapper of `Zotero.Prefs.set`.
* @param key
* @param value
*/
export function setPref<K extends keyof PluginPrefsMap>(
key: K,
value: PluginPrefsMap[K],
) {
return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true);
}
/**
* Clear preference value.
* Wrapper of `Zotero.Prefs.clear`.
* @param key
*/
export function clearPref(key: string) {
return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true);
}
+10
View File
@@ -0,0 +1,10 @@
export { isWindowAlive };
/**
* Check if the window is alive.
* Useful to prevent opening duplicate windows.
* @param win
*/
function isWindowAlive(win?: Window) {
return win && !Components.utils.isDeadWrapper(win) && !win.closed;
}
+48
View File
@@ -0,0 +1,48 @@
import { ZoteroToolkit } from "zotero-plugin-toolkit";
import { config } from "../../package.json";
export { createZToolkit };
function createZToolkit() {
const _ztoolkit = new ZoteroToolkit();
/**
* Alternatively, import toolkit modules you use to minify the plugin size.
* You can add the modules under the `MyToolkit` class below and uncomment the following line.
*/
// const _ztoolkit = new MyToolkit();
initZToolkit(_ztoolkit);
return _ztoolkit;
}
function initZToolkit(_ztoolkit: ReturnType<typeof createZToolkit>) {
const env = __env__;
_ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`;
_ztoolkit.basicOptions.log.disableConsole = env === "production";
_ztoolkit.UI.basicOptions.ui.enableElementJSONLog = __env__ === "development";
_ztoolkit.UI.basicOptions.ui.enableElementDOMLog = __env__ === "development";
// Getting basicOptions.debug will load global modules like the debug bridge.
// since we want to deprecate it, should avoid using it unless necessary.
// _ztoolkit.basicOptions.debug.disableDebugBridgePassword =
// __env__ === "development";
_ztoolkit.basicOptions.api.pluginID = config.addonID;
_ztoolkit.ProgressWindow.setIconURI(
"default",
`chrome://${config.addonRef}/content/icons/favicon.png`,
);
}
import { BasicTool, unregister } from "zotero-plugin-toolkit";
import { UITool } from "zotero-plugin-toolkit";
class MyToolkit extends BasicTool {
UI: UITool;
constructor() {
super();
this.UI = new UITool(this);
}
unregisterAll() {
unregister(this);
}
}