first commit
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user