增加语音对话功能

This commit is contained in:
yhy
2026-08-29 22:13:55 +08:00
parent 1ffa069151
commit 3a2438f566
51 changed files with 6207 additions and 3 deletions
+2
View File
@@ -1,5 +1,6 @@
import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit";
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu";
@@ -15,6 +16,7 @@ async function onStartup() {
initLocale();
registerPrefs();
registerChatPane();
registerLecturePane();
registerReaderSelectionHook();
await Promise.all(
@@ -0,0 +1,124 @@
import type { StoredBeat } from "../infrastructure/storage/lectureStore";
import { pathToFileUrl } from "../infrastructure/platform/paths";
export interface PlayLectureHandle {
play: () => Promise<void>;
pause: () => void;
next: () => Promise<void>;
prev: () => Promise<void>;
jumpToBeatId: (beatId: string) => Promise<void>;
getCurrentBeatId: () => string | undefined;
isPlaying: () => boolean;
destroy: () => void;
setBeats: (beats: StoredBeat[]) => void;
}
export function createPlayLecture(options: {
audio: HTMLAudioElement;
beats: StoredBeat[];
onBeatChange?: (beatId: string | undefined) => void;
onPlayingChange?: (playing: boolean) => void;
onComplete?: () => void;
}): PlayLectureHandle {
let beats = options.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
let index = 0;
let playing = false;
const notifyBeat = () => {
options.onBeatChange?.(beats[index]?.id);
};
const loadIndex = (i: number): boolean => {
if (i < 0 || i >= beats.length) return false;
index = i;
const beat = beats[i];
if (!beat.audioPath) return false;
options.audio.src = pathToFileUrl(beat.audioPath);
options.audio.load();
notifyBeat();
return true;
};
options.audio.onended = () => {
void (async () => {
if (index + 1 < beats.length) {
index += 1;
if (loadIndex(index)) {
try {
await options.audio.play();
} catch {
playing = false;
options.onPlayingChange?.(false);
}
}
} else {
playing = false;
options.onPlayingChange?.(false);
options.onComplete?.();
}
})();
};
return {
setBeats(next) {
beats = next.filter((b) => b.ttsStatus === "ready" && b.audioPath);
if (index >= beats.length) index = Math.max(0, beats.length - 1);
notifyBeat();
},
async play() {
if (!beats.length) return;
if (!options.audio.src || options.audio.paused) {
loadIndex(index);
}
try {
await options.audio.play();
playing = true;
options.onPlayingChange?.(true);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] audio play failed", e);
playing = false;
options.onPlayingChange?.(false);
}
},
pause() {
options.audio.pause();
playing = false;
options.onPlayingChange?.(false);
},
async next() {
if (index + 1 >= beats.length) return;
index += 1;
if (loadIndex(index)) await this.play();
},
async prev() {
if (index <= 0) return;
index -= 1;
if (loadIndex(index)) await this.play();
},
async jumpToBeatId(beatId: string) {
const i = beats.findIndex((b) => b.id === beatId);
if (i < 0) return;
index = i;
if (loadIndex(index)) await this.play();
},
getCurrentBeatId() {
return beats[index]?.id;
},
isPlaying() {
return playing && !options.audio.paused;
},
destroy() {
options.audio.onended = null;
options.audio.pause();
playing = false;
},
};
}
@@ -0,0 +1,284 @@
import type { LectureStatus, PaperCache } from "../domain/types";
import { generateBeats } from "../infrastructure/llm/beatLlm";
import {
formatSummaryForDisplay,
generatePaperSummary,
} from "../infrastructure/llm/lectureLlm";
import {
chunkPaperText,
formatChunkOutline,
} from "../infrastructure/pdf/chunker";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import {
ensureAudioDir,
} from "../infrastructure/storage/audioStorage";
import { lectureEvents } from "../infrastructure/events";
import {
isStep1Ready,
loadLectureData,
mergeLectureData,
type LectureData,
type StoredBeat,
} from "../infrastructure/storage/lectureStore";
import { audioFilePath } from "../infrastructure/storage/paths";
import { paperRepository } from "../infrastructure/storage/repository";
import { synthesizeSpeech } from "../infrastructure/tts/client";
import {
getRegularItem,
resolvePaperAttachment,
} from "../infrastructure/zotero/adapter";
import {
buildMetadataBlock,
extractPdfContext,
} from "../../pdf/extractor";
import { LlmError } from "../../llm/types";
export class PrepareLectureUseCase {
private inflight = new Map<string, Promise<PaperCache>>();
async start(item: Zotero.Item): Promise<PaperCache> {
const ctx = await resolvePaperAttachment(item);
const pending = this.inflight.get(ctx.cacheKey);
if (pending) return pending;
const task = this.runStart(item, ctx);
this.inflight.set(ctx.cacheKey, task);
try {
return await task;
} finally {
this.inflight.delete(ctx.cacheKey);
}
}
private async runStart(
item: Zotero.Item,
ctx: Awaited<ReturnType<typeof resolvePaperAttachment>>,
): Promise<PaperCache> {
let paper = await paperRepository.getByCacheKey(ctx.cacheKey);
const now = Date.now();
const parent = getRegularItem(item);
const paperId = paper?.id ?? paperIdFromCacheKey(ctx.cacheKey);
if (!paper) {
paper = {
id: paperId,
attachmentId: String(ctx.attachment.id),
itemId: String(parent.id),
fileHash: ctx.fileHash,
filePath: ctx.filePath,
title: String(parent.getField("title") || ""),
parseStatus: "pending",
lectureStatus: "idle",
createdAt: now,
updatedAt: now,
};
}
const data = await loadLectureData(ctx.cacheKey);
if (paper.lectureStatus === "ready" && isStep1Ready(data)) {
return paper;
}
await this.runPipeline(paper, ctx.cacheKey, item);
const updated = await paperRepository.getByCacheKey(ctx.cacheKey);
return updated ?? paper;
}
private async runPipeline(
paper: PaperCache,
cacheKey: string,
item: Zotero.Item,
): Promise<void> {
const emitStatus = async (
lectureStatus: LectureStatus,
message: string,
percent?: number,
) => {
paper.lectureStatus = lectureStatus;
paper.updatedAt = Date.now();
await paperRepository.upsert(cacheKey, paper);
lectureEvents.emit("lecture:status", {
paperId: paper.id,
lectureStatus,
message,
});
if (percent != null) {
lectureEvents.emit("parse:progress", {
paperId: paper.id,
percent,
message,
});
}
};
try {
let data: LectureData =
(await loadLectureData(cacheKey)) ?? {
paperId: paper.id,
cacheKey,
updatedAt: Date.now(),
};
const parent = getRegularItem(item);
const title = String(parent.getField("title") || paper.title || "");
// --- PDF extract ---
let extractText = "";
if (!data.summary || !data.chunks?.length) {
paper.parseStatus = "running";
await emitStatus("parsing", "正在读取论文…", 10);
const extract = await extractPdfContext(item);
if (!extract.text) {
throw new Error(
extract.error ||
"未能提取 PDF 文本(可能是扫描版)。语音伴读需要可复制的文本层。",
);
}
extractText = extract.text;
data.chunks = chunkPaperText(extract.text);
data.extractChars = extract.text.length;
data.extractSource = extract.source;
await mergeLectureData(cacheKey, paper.id, data);
await emitStatus(
"parsing",
`已提取 ${extract.text.length} 字符,${data.chunks.length} 个段落块`,
25,
);
paper.parseStatus = "done";
}
// --- Summary ---
if (!data.summary) {
await emitStatus("summarizing", "正在生成全文摘要…", 35);
const extract = await extractPdfContext(item);
const meta = buildMetadataBlock(parent);
const paperText =
extract.text || extractText || data.chunks?.map((c) => c.text).join("\n\n") || "";
try {
data.summary = await generatePaperSummary({
title,
paperText: meta ? `${meta}\n\n${paperText}` : paperText,
});
} catch (e) {
if (e instanceof LlmError) throw e;
throw new Error(e instanceof Error ? e.message : String(e));
}
data.phase = 1;
await mergeLectureData(cacheKey, paper.id, data);
}
// --- Beats ---
if (!data.beats?.length) {
await emitStatus("beats_generating", "正在生成整体讲解 beats…", 50);
const chunks = data.chunks ?? [];
try {
data.beats = await generateBeats({
title,
summary: data.summary!,
chunkOutline: formatChunkOutline(chunks),
});
} catch (e) {
if (e instanceof LlmError) throw e;
throw new Error(e instanceof Error ? e.message : String(e));
}
await mergeLectureData(cacheKey, paper.id, data);
lectureEvents.emit("lecture:beats_ready", {
paperId: paper.id,
beatCount: data.beats.length,
});
}
// --- TTS for beats ---
const beats = data.beats ?? [];
const pendingTts = beats.filter((b) => b.ttsStatus !== "ready");
if (pendingTts.length) {
await emitStatus(
"tts_generating",
`正在合成语音(0/${pendingTts.length})…`,
65,
);
await ensureAudioDir(cacheKey);
let done = 0;
for (const beat of beats) {
if (beat.ttsStatus === "ready" && beat.audioPath) continue;
try {
const outPath = audioFilePath(cacheKey, beat.id, "wav");
const result = await synthesizeSpeech({
text: beat.script,
outputPath: outPath,
lang: "zh",
});
beat.audioPath = result.audioPath;
beat.ttsStatus = "ready";
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] TTS failed for", beat.id, e);
beat.ttsStatus = "failed";
}
done += 1;
data.beats = beats;
await mergeLectureData(cacheKey, paper.id, data);
if (beat.ttsStatus === "ready" && beat.audioPath) {
lectureEvents.emit("tts:unit_ready", {
paperId: paper.id,
unitId: beat.id,
audioPath: beat.audioPath,
});
}
await emitStatus(
"tts_generating",
`正在合成语音(${done}/${pendingTts.length})…`,
65 + Math.floor((done / pendingTts.length) * 30),
);
}
}
if (!beats.some((b) => b.ttsStatus === "ready")) {
throw new Error("所有 beat 语音合成均失败,请检查 TTS 设置。");
}
data.phase = 2;
await mergeLectureData(cacheKey, paper.id, data);
paper.lectureStatus = "ready";
paper.updatedAt = Date.now();
await paperRepository.upsert(cacheKey, paper);
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
const preview = formatSummaryForDisplay(data.summary!);
lectureEvents.emit("lecture:status", {
paperId: paper.id,
lectureStatus: "ready",
message: `备课完成:${readyCount} 段讲解可播放\n${preview}`,
});
lectureEvents.emit("parse:progress", {
paperId: paper.id,
percent: 100,
message: "整体讲解已就绪",
});
} catch (e) {
paper.parseStatus = "failed";
paper.lectureStatus = "failed";
paper.updatedAt = Date.now();
await paperRepository.upsert(cacheKey, paper);
const msg = e instanceof Error ? e.message : String(e);
lectureEvents.emit("lecture:failed", {
paperId: paper.id,
error: msg,
});
throw e;
}
}
}
export const prepareLecture = new PrepareLectureUseCase();
+97
View File
@@ -0,0 +1,97 @@
/** 备课 / 解析流水线状态 */
export type LectureStatus =
| "idle"
| "parsing"
| "parsed"
| "summarizing"
| "beats_generating"
| "paragraphs_generating"
| "tts_generating"
| "ready"
| "failed";
export type ParseStatus = "pending" | "running" | "done" | "failed";
export type BeatType =
| "intro"
| "method"
| "experiment"
| "conclusion"
| "extension";
export type PlayerMode = "overview" | "paragraph";
export type AnchorType = "beat" | "paragraph" | "sentence" | "global";
export interface SummaryJson {
problem: string;
method: string;
result: string;
limitation: string;
}
export interface Chapter {
id: string;
paperId: string;
title: string;
orderIndex: number;
}
export interface Paragraph {
id: string;
paperId: string;
chapterId: string;
orderIndex: number;
text: string;
page: number;
language?: "en" | "zh";
bulletPoints?: string[];
}
export interface Beat {
id: string;
paperId: string;
orderIndex: number;
title?: string;
type: BeatType;
script: string;
refs: string[];
degraded?: boolean;
audioPath?: string;
timestampsJson?: string;
ttsStatus: "pending" | "ready" | "failed";
}
export interface ParagraphExplanation {
paragraphId: string;
paperId: string;
layerWhat: string;
layerHow: string;
layerWhere: string;
audioPath?: string;
timestampsJson?: string;
genStatus: "pending" | "ready" | "failed";
ttsStatus: "pending" | "ready" | "failed";
}
export interface PaperCache {
id: string;
attachmentId: string;
itemId: string;
fileHash: string;
filePath: string;
title?: string;
language?: "en" | "zh";
parseStatus: ParseStatus;
lectureStatus: LectureStatus;
createdAt: number;
updatedAt: number;
}
export interface PaperAttachmentContext {
item: Zotero.Item;
attachment: Zotero.Item;
filePath: string;
fileHash: string;
cacheKey: string;
}
@@ -0,0 +1,58 @@
export type LectureEventMap = {
"parse:progress": { paperId: string; percent: number; message: string };
"lecture:beats_ready": { paperId: string; beatCount: number };
"lecture:paragraph_ready": { paperId: string; paragraphId: string };
"tts:unit_ready": { paperId: string; unitId: string; audioPath: string };
"lecture:failed": { paperId: string; error: string };
"lecture:status": {
paperId: string;
lectureStatus: string;
message?: string;
};
};
type Handler<K extends keyof LectureEventMap> = (
detail: LectureEventMap[K],
) => void;
/**
* In-process event bus for lecture pipeline.
* Avoids EventTarget / CustomEvent — not available in Zotero chrome scopes.
*/
class LectureEventBus {
private listeners = new Map<
keyof LectureEventMap,
Set<Handler<keyof LectureEventMap>>
>();
on<K extends keyof LectureEventMap>(
type: K,
handler: Handler<K>,
): () => void {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
const fn = handler as Handler<keyof LectureEventMap>;
set.add(fn);
return () => set.delete(fn);
}
emit<K extends keyof LectureEventMap>(
type: K,
detail: LectureEventMap[K],
): void {
const set = this.listeners.get(type);
if (!set) return;
for (const handler of set) {
try {
handler(detail);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] event handler error", type, e);
}
}
}
}
export const lectureEvents = new LectureEventBus();
@@ -0,0 +1,131 @@
import { LlmError } from "../../../llm/types";
import type { BeatType, SummaryJson } from "../../domain/types";
import type { StoredBeat } from "../storage/lectureStore";
import { parseJsonLoose, requestStructuredOutput } from "./structuredOutput";
const VALID_TYPES = new Set<BeatType>([
"intro",
"method",
"experiment",
"conclusion",
"extension",
]);
const BEATS_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文摘要与段落索引,生成「整体讲解」beats。
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
格式:{"beats":[{"title":"小节标题","type":"intro","script":"口语讲解文本"}]}
要求:
- beats 数组含 6–10 项,按教学逻辑:问题引入 → 核心方法 → 关键实验/证据 → 结论与局限
- script 为中文口语,每段 80–180 字,适合朗读;不要念公式符号,用白话解释
- type 只能是 intro / method / experiment / conclusion / extension`;
interface BeatJson {
title?: string;
type?: string;
script?: string;
content?: string;
text?: string;
}
function beatScript(b: BeatJson): string {
return String(b.script || b.content || b.text || "").trim();
}
function normalizeBeatType(raw?: string): BeatType {
const t = String(raw || "intro").toLowerCase() as BeatType;
return VALID_TYPES.has(t) ? t : "intro";
}
function toStoredBeats(items: BeatJson[]): StoredBeat[] {
return items
.map((b) => ({ ...b, script: beatScript(b) }))
.filter((b) => b.script)
.slice(0, 12)
.map((b, orderIndex) => ({
id: `beat-${orderIndex}`,
orderIndex,
title: String(b.title || `${orderIndex + 1}`).trim(),
type: normalizeBeatType(b.type),
script: b.script,
refs: [],
ttsStatus: "pending" as const,
}));
}
function parseBeatsJson(raw: string): StoredBeat[] {
const parsed = parseJsonLoose<BeatJson[] | { beats?: BeatJson[] }>(raw);
const items = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.beats)
? parsed.beats!
: [];
const beats = toStoredBeats(items);
if (beats.length) return beats;
throw new LlmError("provider", "beats 数组为空或格式无效,请重试。");
}
/** Deterministic fallback when JSON parsing fails — still usable for TTS. */
export function beatsFromSummaryFallback(summary: SummaryJson): StoredBeat[] {
const templates: Array<{ title: string; type: BeatType; script: string }> = [
{ title: "研究问题", type: "intro", script: summary.problem },
{ title: "核心方法", type: "method", script: summary.method },
{
title: "方法细节",
type: "method",
script: `${summary.method} 这部分是理解全文的关键,建议结合原文图表再听一遍。`,
},
{ title: "主要结果", type: "experiment", script: summary.result },
{ title: "结论与局限", type: "conclusion", script: summary.limitation },
];
return templates
.filter((t) => t.script.trim())
.map((t, orderIndex) => ({
id: `beat-${orderIndex}`,
orderIndex,
title: t.title,
type: t.type,
script: t.script.trim(),
refs: [],
ttsStatus: "pending" as const,
}));
}
export async function generateBeats(options: {
title: string;
summary: SummaryJson;
chunkOutline: string;
}): Promise<StoredBeat[]> {
const summaryBlock = [
`问题:${options.summary.problem}`,
`方法:${options.summary.method}`,
`结果:${options.summary.result}`,
`局限:${options.summary.limitation}`,
].join("\n");
return requestStructuredOutput({
system: BEATS_SYSTEM,
user: `【论文标题】${options.title}
【全文摘要】
${summaryBlock}
【段落索引(供参考)】
${options.chunkOutline.slice(0, 8000)}
请输出 {"beats":[...]} JSON。`,
parse: parseBeatsJson,
jsonMode: true,
fallback: () => beatsFromSummaryFallback(options.summary),
});
}
export const BEAT_TYPE_LABELS: Record<BeatType, string> = {
intro: "引入",
method: "方法",
experiment: "实验",
conclusion: "结论",
extension: "延伸",
};
@@ -0,0 +1,67 @@
import { getRuntimeConfig } from "../../../llm/client";
import { parseJsonLoose, parseLabeledFields } from "../../../llm/jsonParse";
import { LlmError } from "../../../llm/types";
import type { SummaryJson } from "../../domain/types";
import { requestStructuredOutput } from "./structuredOutput";
const SUMMARY_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文正文生成结构化摘要。
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
格式:{"problem":"...","method":"...","result":"...","limitation":"..."}
每项 1-3 句中文,准确、简洁,基于正文,不要编造。`;
const SUMMARY_LABELS = [
{ field: "problem", labels: ["问题", "研究问题", "problem"] },
{ field: "method", labels: ["方法", "核心方法", "method"] },
{ field: "result", labels: ["结果", "主要结果", "result"] },
{ field: "limitation", labels: ["局限", "局限性", "limitation"] },
];
function normalizeSummary(obj: Record<string, unknown>): SummaryJson | undefined {
const problem = String(obj.problem || "").trim();
const method = String(obj.method || "").trim();
const result = String(obj.result || obj.results || "").trim();
const limitation = String(obj.limitation || obj.limitations || "").trim();
if (!problem || !method) return undefined;
return {
problem,
method,
result: result || "(模型未单独给出结果,请重试或换模型)",
limitation: limitation || "(模型未单独给出局限,请重试或换模型)",
};
}
function parseSummaryJson(raw: string): SummaryJson {
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
const summary = normalizeSummary(parsed);
if (summary) return summary;
const labeled = parseLabeledFields(raw, SUMMARY_LABELS);
const fromLabels = normalizeSummary(labeled);
if (fromLabels) return fromLabels;
throw new LlmError("provider", "模型未返回有效 JSON 摘要,请重试。");
}
export async function generatePaperSummary(options: {
title: string;
paperText: string;
}): Promise<SummaryJson> {
const cfg = getRuntimeConfig();
const text = options.paperText.slice(0, Number(cfg.maxTokens) * 3 || 120000);
return requestStructuredOutput({
system: SUMMARY_SYSTEM,
user: `【论文标题】${options.title}\n\n【论文正文】\n${text}\n\n请输出 JSON 摘要。`,
parse: parseSummaryJson,
jsonMode: true,
});
}
export function formatSummaryForDisplay(summary: SummaryJson): string {
return [
`问题:${summary.problem}`,
`方法:${summary.method}`,
`结果:${summary.result}`,
`局限:${summary.limitation}`,
].join("\n");
}
@@ -0,0 +1,95 @@
import { chatStream } from "../../../llm/client";
import { stripMarkdownFence } from "../../../llm/jsonParse";
import type { ChatMessage } from "../../../llm/types";
import { LlmError } from "../../../llm/types";
export interface StructuredRequestOptions<T> {
system: string;
user: string;
parse: (raw: string) => T;
/** OpenAI-compatible json_object mode when supported */
jsonMode?: boolean;
maxAttempts?: number;
fallback?: () => T;
}
async function callLlm(
messages: ChatMessage[],
jsonMode: boolean,
): Promise<string> {
try {
return await chatStream({ messages, onDelta: () => {}, jsonMode });
} catch (e) {
if (!jsonMode) throw e;
// Local / older endpoints may reject response_format
ztoolkit.log("[ChatPapers] jsonMode rejected, retrying without", e);
return chatStream({ messages, onDelta: () => {}, jsonMode: false });
}
}
/**
* Request structured LLM output with parse retries + optional JSON repair pass.
*/
export async function requestStructuredOutput<T>(
options: StructuredRequestOptions<T>,
): Promise<T> {
const maxAttempts = options.maxAttempts ?? 3;
let lastRaw = "";
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let messages: ChatMessage[];
if (attempt === 0) {
messages = [
{ role: "system", content: options.system },
{ role: "user", content: options.user },
];
} else if (attempt === 1) {
messages = [
{
role: "system",
content: `${options.system}\n\n重要:只输出 JSON,不要 markdown 代码块,不要前后解释。`,
},
{ role: "user", content: options.user },
];
} else {
messages = [
{
role: "system",
content:
"你是 JSON 修复助手。根据用户给出的内容输出修复后的合法 JSON。只输出 JSON本身,不要其他文字。",
},
{
role: "user",
content: `以下内容解析失败,请输出修复后的 JSON:\n${stripMarkdownFence(lastRaw).slice(0, 14000)}`,
},
];
}
try {
const useJsonMode = options.jsonMode !== false && attempt < 2;
lastRaw = await callLlm(messages, useJsonMode);
return options.parse(lastRaw);
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e));
if (e instanceof LlmError && (e as LlmError).code !== "provider") throw e;
ztoolkit.log(
`[ChatPapers:Lecture] structured output attempt ${attempt + 1} failed`,
e,
);
}
}
if (options.fallback) {
ztoolkit.log("[ChatPapers:Lecture] using structured output fallback");
return options.fallback();
}
throw (
lastError ??
new LlmError("provider", "模型多次未能返回可解析的 JSON,请更换模型后重试。")
);
}
export { parseJsonLoose } from "../../../llm/jsonParse";
@@ -0,0 +1,55 @@
/** Coarse paragraph chunks from PDFWorker plain text (MinerU MVP fallback). */
export interface TextChunk {
id: string;
orderIndex: number;
text: string;
}
const MIN_CHUNK_CHARS = 200;
const MAX_CHUNK_CHARS = 900;
const MAX_CHUNKS = 40;
export function chunkPaperText(text: string): TextChunk[] {
const paragraphs = text
.split(/\n\s*\n+/)
.map((s) => s.replace(/\s+/g, " ").trim())
.filter((s) => s.length > 40);
const merged: string[] = [];
let buffer = "";
for (const para of paragraphs) {
if (
buffer &&
(buffer.length + para.length > MAX_CHUNK_CHARS ||
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
) {
merged.push(buffer);
buffer = para;
} else {
buffer = buffer ? `${buffer} ${para}` : para;
}
}
if (buffer) merged.push(buffer);
if (!merged.length && text.trim()) {
merged.push(text.trim().slice(0, MAX_CHUNK_CHARS));
}
return merged.slice(0, MAX_CHUNKS).map((chunkText, orderIndex) => ({
id: `p-${orderIndex}`,
orderIndex,
text: chunkText.slice(0, 2000),
}));
}
/** Compact outline for LLM beats generation. */
export function formatChunkOutline(chunks: TextChunk[]): string {
return chunks
.map(
(c) =>
`[${c.id}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`,
)
.join("\n");
}
@@ -0,0 +1,25 @@
/**
* SHA-256 file hash for lecture cache invalidation.
* Uses Web Crypto (available in Zotero/Firefox) — works on macOS and Windows.
*/
export async function computeFileHash(filePath: string): Promise<string> {
const data = await IOUtils.read(filePath);
const view = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
const digest = await crypto.subtle.digest("SHA-256", view);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export function buildCacheKey(attachmentId: string, fileHash: string): string {
return `${attachmentId}|${fileHash}`;
}
export function paperIdFromCacheKey(cacheKey: string): string {
// Stable id for JSON store; SQLite POC may use hash of cacheKey
let hash = 0;
for (let i = 0; i < cacheKey.length; i++) {
hash = (hash * 31 + cacheKey.charCodeAt(i)) | 0;
}
return `paper-${Math.abs(hash).toString(36)}`;
}
@@ -0,0 +1,62 @@
import { isMac, isWindows } from "./os";
import { joinPath, normalizeExecutablePath } from "./paths";
/** Well-known absolute paths before PATH lookup */
const SYSTEM_HINTS: Record<string, string[]> = {
say: ["/usr/bin/say"],
afconvert: ["/usr/bin/afconvert"],
piper: ["/usr/local/bin/piper", "/opt/homebrew/bin/piper"],
powershell: [
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
],
ffmpeg: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
};
export async function resolveExecutable(
name: string,
customPath?: string,
): Promise<string> {
const trimmed = (customPath || "").trim();
if (trimmed) {
const normalized = normalizeExecutablePath(trimmed);
if (await IOUtils.exists(normalized)) return normalized;
throw new Error(`可执行文件不存在: ${normalized}`);
}
const hints = SYSTEM_HINTS[name] || [];
for (const hint of hints) {
try {
if (await IOUtils.exists(hint)) return hint;
} catch {
// ignore
}
}
// Fallback: bare name (relies on PATH when process starts — may fail in Zotero)
return normalizeExecutablePath(name);
}
export async function writeTempTextFile(text: string, prefix: string): Promise<string> {
const dir = PathUtils.tempDir;
const path = joinPath(dir, `chatpapers-${prefix}-${Date.now()}.txt`);
await IOUtils.writeUTF8(path, text);
return path;
}
export async function removeIfExists(path: string): Promise<void> {
try {
if (await IOUtils.exists(path)) await IOUtils.remove(path);
} catch {
// ignore
}
}
export function defaultVoiceForLang(lang?: "zh" | "en"): string {
if (isMac()) {
return lang === "en" ? "Samantha" : "Ting-Ting";
}
if (isWindows()) {
return lang === "en" ? "Microsoft Zira Desktop" : "Microsoft Huihui Desktop";
}
return "";
}
@@ -0,0 +1,30 @@
export type HostPlatform = "mac" | "win" | "linux" | "other";
/**
* Resolve host OS for path / subprocess branching.
* Uses Services.appinfo.OS (Darwin | WINNT | Linux | …).
*/
export function getHostPlatform(): HostPlatform {
try {
const os = String(Services.appinfo.OS || "").toLowerCase();
if (os.includes("darwin")) return "mac";
if (os.includes("win")) return "win";
if (os.includes("linux")) return "linux";
} catch {
// ignore
}
return "other";
}
export function isWindows(): boolean {
return getHostPlatform() === "win";
}
export function isMac(): boolean {
return getHostPlatform() === "mac";
}
/** Path list separator (`PATH` env): `;` on Windows, `:` elsewhere. */
export function pathListSeparator(): string {
return isWindows() ? ";" : ":";
}
@@ -0,0 +1,33 @@
import { isWindows } from "./os";
/**
* Normalize user-provided executable path for the current OS.
* - Expands ~ on macOS/Linux via PathUtils (when available)
* - Ensures .exe suffix on Windows when missing
*/
export function normalizeExecutablePath(input: string): string {
const trimmed = (input || "").trim();
if (!trimmed) return "";
const path = trimmed;
if (isWindows() && !/\.(exe|cmd|bat)$/i.test(path)) {
return `${path}.exe`;
}
return path;
}
/** Join path segments using Zotero PathUtils (cross-platform). */
export function joinPath(...parts: string[]): string {
return PathUtils.join(...parts.filter(Boolean));
}
/** Convert local file path to file:// URL for <audio src>. */
export function pathToFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.startsWith("file://")) return normalized;
if (/^[A-Za-z]:\//.test(normalized)) {
return `file:///${encodeURI(normalized)}`;
}
return `file://${encodeURI(normalized)}`;
}
@@ -0,0 +1,144 @@
import { getHostPlatform, isWindows, pathListSeparator } from "./os";
import { normalizeExecutablePath } from "./paths";
import { getPref } from "../../../../utils/prefs";
export interface RunCommandOptions {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
}
export interface RunCommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
/**
* Build a platform-aware command argv.
* On Windows, wraps `.cmd` / `.bat` via cmd.exe when needed.
*/
export function buildCommandArgv(
executable: string,
args: string[],
): string[] {
const exe = normalizeExecutablePath(executable);
if (!exe) throw new Error("Executable path is empty");
if (isWindows() && /\.(cmd|bat)$/i.test(exe)) {
return ["cmd.exe", "/c", exe, ...args];
}
return [exe, ...args];
}
export function resolveMinerUCommand(customPath?: string): {
executable: string;
args: string[];
platform: ReturnType<typeof getHostPlatform>;
} {
const platform = getHostPlatform();
const trimmed = (customPath || "").trim();
if (trimmed) {
return { executable: normalizeExecutablePath(trimmed), args: [], platform };
}
if (platform === "win") {
return { executable: "mineru", args: [], platform };
}
return { executable: "mineru", args: [], platform };
}
export function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) return [];
const sep = pathListSeparator();
return pathValue.split(sep).map((p) => p.trim()).filter(Boolean);
}
function getNsIProcess(): MozNsIProcess {
return Components.classes["@mozilla.org/process/util;1"].createInstance(
Components.interfaces.nsIProcess,
) as MozNsIProcess;
}
function pathToNsIFile(path: string): MozNsIFile {
const file = Zotero.File.pathToFile(path);
if (!file?.exists()) {
throw new Error(`找不到可执行文件: ${path}`);
}
if (file.isExecutable && !file.isExecutable()) {
throw new Error(`文件不可执行: ${path}`);
}
return file;
}
async function runViaZoteroExec(
exePath: string,
procArgs: string[],
): Promise<RunCommandResult | undefined> {
const exec = (Zotero as any).Utilities?.Internal?.exec;
if (typeof exec !== "function") return undefined;
try {
await exec(exePath, procArgs);
return { exitCode: 0, stdout: "", stderr: "" };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`子进程执行失败 (${exePath}): ${msg}`);
}
}
function runViaNsIProcess(
exePath: string,
procArgs: string[],
options: RunCommandOptions,
): RunCommandResult {
const proc = getNsIProcess();
proc.init(pathToNsIFile(exePath));
proc.startHidden = true;
if (options.cwd) {
try {
(proc as any).workingDirectory = pathToNsIFile(options.cwd);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] workingDirectory unsupported", e);
}
}
// run()/runw() return void — never treat return value as success flag
try {
proc.run(true, procArgs, procArgs.length);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`无法启动子进程 (${exePath}): ${msg}`);
}
const code = proc.exitValue ?? 0;
if (code !== 0) {
throw new Error(`子进程退出码 ${code}: ${exePath}`);
}
return { exitCode: code, stdout: "", stderr: "" };
}
/**
* Run external command (blocking).
* Prefers Zotero.Utilities.Internal.exec; falls back to nsIProcess.run.
*/
export async function runCommand(
executable: string,
args: string[],
options: RunCommandOptions = {},
): Promise<RunCommandResult> {
const argv = buildCommandArgv(executable, args);
const exePath = argv[0]!;
const procArgs = argv.slice(1);
if (getPref("verboseLog")) {
ztoolkit.log("[ChatPapers:Lecture] runCommand", exePath, procArgs);
}
const viaExec = await runViaZoteroExec(exePath, procArgs);
if (viaExec) return viaExec;
return runViaNsIProcess(exePath, procArgs, options);
}
@@ -0,0 +1,40 @@
import {
audioDirForPaper,
audioFilePath,
ensureLectureDirs,
} from "./paths";
import { pathToFileUrl } from "../platform/paths";
export async function ensureAudioDir(cacheKey: string): Promise<string> {
await ensureLectureDirs();
const dir = audioDirForPaper(cacheKey);
await IOUtils.makeDirectory(dir, {
createAncestors: true,
ignoreExisting: true,
});
return dir;
}
export function resolveUnitAudioPath(
cacheKey: string,
unitId: string,
): string {
return audioFilePath(cacheKey, unitId);
}
export async function audioFileExists(
cacheKey: string,
unitId: string,
): Promise<boolean> {
const path = audioFilePath(cacheKey, unitId);
try {
return await IOUtils.exists(path);
} catch {
return false;
}
}
/** file:// URL safe for HTML5 audio element */
export function audioFileUrl(cacheKey: string, unitId: string): string {
return pathToFileUrl(audioFilePath(cacheKey, unitId));
}
@@ -0,0 +1,95 @@
import type { BeatType, SummaryJson } from "../../domain/types";
import type { TextChunk } from "../pdf/chunker";
import { joinPath } from "../platform/paths";
import { ensureLectureDirs, lectureDataRoot } from "./paths";
export interface StoredBeat {
id: string;
orderIndex: number;
title?: string;
type: BeatType;
script: string;
refs: string[];
audioPath?: string;
ttsStatus: "pending" | "ready" | "failed";
}
export interface LectureData {
paperId: string;
cacheKey: string;
/** 1 = summary only, 2 = beats + TTS (Step 1) */
phase?: 1 | 2;
summary?: SummaryJson;
chunks?: TextChunk[];
beats?: StoredBeat[];
extractChars?: number;
extractSource?: string;
updatedAt: number;
}
function lectureDataPath(cacheKey: string): string {
const safe = cacheKey.replace(/[^a-zA-Z0-9_|.-]/g, "_");
return joinPath(lectureDataRoot(), "data", `${safe}.json`);
}
export async function loadLectureData(
cacheKey: string,
): Promise<LectureData | undefined> {
await ensureLectureDirs();
const path = lectureDataPath(cacheKey);
try {
if (!(await IOUtils.exists(path))) return undefined;
const raw = await IOUtils.readUTF8(path);
return JSON.parse(raw) as LectureData;
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] loadLectureData failed", e);
return undefined;
}
}
export async function saveLectureData(data: LectureData): Promise<void> {
await ensureLectureDirs();
const dir = joinPath(lectureDataRoot(), "data");
await IOUtils.makeDirectory(dir, {
createAncestors: true,
ignoreExisting: true,
});
data.updatedAt = Date.now();
await IOUtils.writeUTF8(
lectureDataPath(data.cacheKey),
JSON.stringify(data, null, 2),
);
}
export async function mergeLectureData(
cacheKey: string,
paperId: string,
patch: Partial<LectureData>,
): Promise<LectureData> {
const existing =
(await loadLectureData(cacheKey)) ??
({
paperId,
cacheKey,
updatedAt: Date.now(),
} satisfies LectureData);
const merged: LectureData = {
...existing,
...patch,
paperId,
cacheKey,
updatedAt: Date.now(),
};
await saveLectureData(merged);
return merged;
}
export function isStep1Ready(data: LectureData | undefined): boolean {
if (!data?.beats?.length) return false;
return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath);
}
export function playableBeats(data: LectureData | undefined): StoredBeat[] {
if (!data?.beats) return [];
return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
}
@@ -0,0 +1,66 @@
import { joinPath } from "../platform/paths";
function profileDir(): string {
const profile =
(Zotero as any).Profile?.dir ||
(Zotero as any).getProfileDirectory?.()?.path;
if (!profile) {
throw new Error("Cannot resolve Zotero profile directory");
}
return profile;
}
/** `{Profile}/chatpapers/lecture/` — voice lecture data root */
export function lectureDataRoot(): string {
return joinPath(profileDir(), "chatpapers", "lecture");
}
export function lectureDbPath(): string {
return joinPath(lectureDataRoot(), "lecture.db");
}
/** Interim JSON index until SQLite POC lands */
export function lectureIndexPath(): string {
return joinPath(lectureDataRoot(), "cache-index.json");
}
export function audioRootDir(): string {
return joinPath(lectureDataRoot(), "audio");
}
export function audioDirForPaper(cacheKey: string): string {
return joinPath(audioRootDir(), cacheKey);
}
export function audioFilePath(
cacheKey: string,
unitId: string,
ext: "wav" | "mp3" = "wav",
): string {
return joinPath(audioDirForPaper(cacheKey), `${unitId}.${ext}`);
}
export function lectureSamplePath(name: string): string {
return joinPath(lectureDataRoot(), "samples", name);
}
export async function ensureSampleDir(): Promise<string> {
const dir = joinPath(lectureDataRoot(), "samples");
await IOUtils.makeDirectory(dir, {
createAncestors: true,
ignoreExisting: true,
});
return dir;
}
export async function ensureLectureDirs(): Promise<void> {
const root = lectureDataRoot();
await IOUtils.makeDirectory(root, {
createAncestors: true,
ignoreExisting: true,
});
await IOUtils.makeDirectory(audioRootDir(), {
createAncestors: true,
ignoreExisting: true,
});
}
@@ -0,0 +1,85 @@
import type { PaperCache } from "../../domain/types";
import { buildCacheKey } from "../pdf/fileHash";
import {
ensureLectureDirs,
lectureIndexPath,
} from "./paths";
interface IndexFile {
schemaVersion: number;
/** keyed by cacheKey (attachmentId|fileHash) */
papers: Record<string, PaperCache>;
}
const SCHEMA_VERSION = 2;
function migratePapers(
papers: Record<string, PaperCache>,
): Record<string, PaperCache> {
const out: Record<string, PaperCache> = {};
for (const [key, paper] of Object.entries(papers)) {
const cacheKey =
key.includes("|") && key.includes(paper.fileHash)
? key
: buildCacheKey(paper.attachmentId, paper.fileHash);
out[cacheKey] = paper;
}
return out;
}
async function readIndex(): Promise<IndexFile> {
await ensureLectureDirs();
const path = lectureIndexPath();
try {
if (!(await IOUtils.exists(path))) {
return { schemaVersion: SCHEMA_VERSION, papers: {} };
}
const raw = await IOUtils.readUTF8(path);
const data = JSON.parse(raw) as IndexFile;
const papers = migratePapers(data.papers ?? {});
return {
schemaVersion: SCHEMA_VERSION,
papers,
};
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] readIndex failed", e);
return { schemaVersion: SCHEMA_VERSION, papers: {} };
}
}
async function writeIndex(index: IndexFile): Promise<void> {
await ensureLectureDirs();
const path = lectureIndexPath();
await IOUtils.writeUTF8(
path,
JSON.stringify({ ...index, schemaVersion: SCHEMA_VERSION }, null, 2),
);
}
export class PaperRepository {
async getByCacheKey(cacheKey: string): Promise<PaperCache | undefined> {
const index = await readIndex();
return index.papers[cacheKey];
}
async getByAttachment(
attachmentId: string,
fileHash: string,
): Promise<PaperCache | undefined> {
const cacheKey = `${attachmentId}|${fileHash}`;
return this.getByCacheKey(cacheKey);
}
async upsert(cacheKey: string, paper: PaperCache): Promise<void> {
const index = await readIndex();
index.papers[cacheKey] = paper;
await writeIndex(index);
}
async listAll(): Promise<PaperCache[]> {
const index = await readIndex();
return Object.values(index.papers);
}
}
export const paperRepository = new PaperRepository();
@@ -0,0 +1,98 @@
/**
* SQLite schema for voice lecture mode.
* Applied when sql.js / native SQLite backend is confirmed in Phase 0 POC.
*/
export const LECTURE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS paper_cache (
id TEXT PRIMARY KEY,
attachment_id TEXT NOT NULL,
item_id TEXT NOT NULL,
file_hash TEXT NOT NULL,
file_path TEXT NOT NULL,
title TEXT,
language TEXT,
parse_status TEXT NOT NULL,
lecture_status TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(attachment_id, file_hash)
);
CREATE TABLE IF NOT EXISTS chapter (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL,
title TEXT NOT NULL,
order_index INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS paragraph (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL,
chapter_id TEXT,
order_index INTEGER NOT NULL,
text TEXT NOT NULL,
page INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lecture (
paper_id TEXT PRIMARY KEY,
summary_json TEXT NOT NULL,
prompt_version TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS beat (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL,
order_index INTEGER NOT NULL,
title TEXT,
type TEXT NOT NULL,
script TEXT NOT NULL,
refs_json TEXT NOT NULL,
audio_path TEXT,
timestamps_json TEXT,
tts_status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS paragraph_explanation (
paragraph_id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL,
layer_what TEXT NOT NULL,
layer_how TEXT NOT NULL,
layer_where TEXT NOT NULL,
audio_path TEXT,
timestamps_json TEXT,
gen_status TEXT NOT NULL,
tts_status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
paper_id TEXT PRIMARY KEY,
last_mode TEXT,
last_unit_id TEXT,
last_position_ms INTEGER DEFAULT 0,
listened_units_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS qa_record (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL,
anchor_type TEXT NOT NULL,
anchor_id TEXT,
question TEXT NOT NULL,
answer TEXT NOT NULL,
audio_path TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS highlight (
paper_id TEXT NOT NULL,
paragraph_id TEXT NOT NULL,
note TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (paper_id, paragraph_id)
);
`;
export const LECTURE_SCHEMA_VERSION = 1;
@@ -0,0 +1,64 @@
import { getPref } from "../../../../utils/prefs";
import {
createTtsProvider,
getDefaultTtsProviderId,
TTS_PROVIDER_PRESETS,
type TtsProviderId,
} from "./providers";
import type { TtsResult, TtsSynthesizeOptions } from "./types";
import { TtsError } from "./types";
export interface TtsRuntimeConfig {
provider: TtsProviderId | string;
voice: string;
piperPath: string;
piperModelPath: string;
}
export function getTtsRuntimeConfig(): TtsRuntimeConfig {
const provider =
(getPref("ttsProvider") as string) || getDefaultTtsProviderId();
return {
provider,
voice: (getPref("ttsVoice") as string) || "",
piperPath: (getPref("piperPath") as string) || "",
piperModelPath: (getPref("piperModelPath") as string) || "",
};
}
export function getTtsProviderLabel(id: string): string {
const preset = TTS_PROVIDER_PRESETS[id as TtsProviderId];
return preset?.label || id;
}
export async function synthesizeSpeech(
options: TtsSynthesizeOptions,
): Promise<TtsResult> {
const cfg = getTtsRuntimeConfig();
if (!cfg.provider) {
throw new TtsError("请先在偏好设置中选择 TTS Provider", "config");
}
try {
const provider = createTtsProvider(cfg.provider);
return await provider.synthesize(options);
} catch (e) {
if (e instanceof TtsError) throw e;
throw new TtsError(
e instanceof Error ? e.message : String(e),
"runtime",
);
}
}
/** Short sample for prefs / lecture pane smoke test */
export const TTS_SAMPLE_ZH =
"你好,我是 ChatPapers 语音伴读。这是一段本地语音合成测试。";
export async function synthesizeSample(outputPath: string): Promise<TtsResult> {
return synthesizeSpeech({
text: TTS_SAMPLE_ZH,
outputPath,
lang: "zh",
});
}
@@ -0,0 +1,84 @@
import {
defaultVoiceForLang,
removeIfExists,
resolveExecutable,
writeTempTextFile,
} from "../../platform/executable";
import { runCommand } from "../../platform/subprocess";
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
import { TtsError } from "../types";
import { getPref } from "../../../../../utils/prefs";
async function estimateWavDurationMs(path: string): Promise<number> {
try {
const stat = await IOUtils.stat(path);
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
} catch {
return 0;
}
}
export function createMacSayProvider(): TtsProvider {
return {
id: "system-mac",
label: "macOS say",
kind: "local",
supportsTimestamps: false,
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
const say = await resolveExecutable("say");
const afconvert = await resolveExecutable("afconvert");
const voice =
options.voice ||
(getPref("ttsVoice") as string) ||
defaultVoiceForLang(options.lang);
const outWav = options.outputPath.endsWith(".wav")
? options.outputPath
: options.outputPath.replace(/\.\w+$/, ".wav");
const aiffPath = outWav.replace(/\.wav$/i, ".aiff");
const textPath = await writeTempTextFile(options.text, "say-text");
try {
const sayResult = await runCommand(say, [
"-v",
voice,
"-f",
textPath,
"-o",
aiffPath,
]);
if (sayResult.exitCode !== 0) {
throw new TtsError(`say 退出码 ${sayResult.exitCode}`, "runtime");
}
const conv = await runCommand(afconvert, [
"-f",
"WAVE",
"-d",
"LEI16",
aiffPath,
outWav,
]);
if (conv.exitCode !== 0) {
throw new TtsError(`afconvert 退出码 ${conv.exitCode}`, "runtime");
}
if (!(await IOUtils.exists(outWav))) {
throw new TtsError("未生成音频文件", "runtime");
}
return {
audioPath: outWav,
timestamps: [],
durationMs: await estimateWavDurationMs(outWav),
format: "wav",
};
} finally {
await removeIfExists(textPath);
await removeIfExists(aiffPath);
}
},
};
}
@@ -0,0 +1,84 @@
import { joinPath } from "../../platform/paths";
import {
removeIfExists,
resolveExecutable,
writeTempTextFile,
} from "../../platform/executable";
import { runCommand } from "../../platform/subprocess";
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
import { TtsError } from "../types";
import { getPref } from "../../../../../utils/prefs";
async function estimateWavDurationMs(path: string): Promise<number> {
try {
const stat = await IOUtils.stat(path);
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
} catch {
return 0;
}
}
export function createPiperProvider(): TtsProvider {
return {
id: "piper",
label: "Piper",
kind: "local",
supportsTimestamps: false,
needsPath: true,
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
const piperPath = (getPref("piperPath") || "") as string;
const modelPath = (getPref("piperModelPath") || "") as string;
if (!modelPath) {
throw new TtsError(
"请先在偏好设置 → 语音伴读 中配置 Piper 模型路径(.onnx",
"config",
);
}
if (!(await IOUtils.exists(modelPath))) {
throw new TtsError(`Piper 模型不存在: ${modelPath}`, "config");
}
const piper = await resolveExecutable("piper", piperPath);
const outWav = options.outputPath.endsWith(".wav")
? options.outputPath
: options.outputPath.replace(/\.\w+$/, ".wav");
const textPath = await writeTempTextFile(options.text, "piper-text");
try {
// piper --model model.onnx --output_file out.wav --file input.txt
const result = await runCommand(piper, [
"--model",
modelPath,
"--output_file",
outWav,
"--file",
textPath,
]);
if (result.exitCode !== 0) {
throw new TtsError(`piper 退出码 ${result.exitCode}`, "runtime");
}
if (!(await IOUtils.exists(outWav))) {
throw new TtsError("Piper 未生成音频文件", "runtime");
}
return {
audioPath: outWav,
timestamps: [],
durationMs: await estimateWavDurationMs(outWav),
format: "wav",
};
} finally {
await removeIfExists(textPath);
}
},
};
}
/** Suggested default model directory hint for prefs UI */
export function piperModelHint(): string {
return joinPath("{HOME}", ".local", "share", "piper", "models");
}
@@ -0,0 +1,93 @@
import { joinPath } from "../../platform/paths";
import {
defaultVoiceForLang,
removeIfExists,
resolveExecutable,
writeTempTextFile,
} from "../../platform/executable";
import { runCommand } from "../../platform/subprocess";
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
import { TtsError } from "../types";
import { getPref } from "../../../../../utils/prefs";
async function estimateWavDurationMs(path: string): Promise<number> {
try {
const stat = await IOUtils.stat(path);
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
} catch {
return 0;
}
}
export function createWindowsSapiProvider(): TtsProvider {
return {
id: "system-win",
label: "Windows SAPI",
kind: "local",
supportsTimestamps: false,
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
const powershell = await resolveExecutable("powershell");
const voice =
options.voice ||
(getPref("ttsVoice") as string) ||
defaultVoiceForLang(options.lang);
const outWav = options.outputPath.endsWith(".wav")
? options.outputPath
: options.outputPath.replace(/\.\w+$/, ".wav");
const textPath = await writeTempTextFile(options.text, "sapi-text");
const scriptPath = joinPath(
PathUtils.tempDir,
`chatpapers-sapi-${Date.now()}.ps1`,
);
const psScript = [
"Add-Type -AssemblyName System.Speech",
"$text = Get-Content -LiteralPath $args[0] -Raw -Encoding UTF8",
"$out = $args[1]",
"$voice = $args[2]",
"$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer",
"if ($voice) { try { $synth.SelectVoice($voice) } catch {} }",
"$synth.SetOutputToWaveFile($out)",
"$synth.Speak($text)",
"$synth.Dispose()",
].join("\n");
await IOUtils.writeUTF8(scriptPath, psScript);
try {
const result = await runCommand(powershell, [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
textPath,
outWav,
voice,
]);
if (result.exitCode !== 0) {
throw new TtsError(
`PowerShell SAPI 退出码 ${result.exitCode}`,
"runtime",
);
}
if (!(await IOUtils.exists(outWav))) {
throw new TtsError("SAPI 未生成音频文件", "runtime");
}
return {
audioPath: outWav,
timestamps: [],
durationMs: await estimateWavDurationMs(outWav),
format: "wav",
};
} finally {
await removeIfExists(textPath);
await removeIfExists(scriptPath);
}
},
};
}
@@ -0,0 +1,70 @@
import { getHostPlatform, isMac, isWindows } from "../platform/os";
import type { TtsProvider } from "./types";
import { createMacSayProvider } from "./local/macSay";
import { createPiperProvider } from "./local/piper";
import { createWindowsSapiProvider } from "./local/windowsSapi";
export interface TtsProviderPreset {
id: string;
label: string;
kind: "local" | "cloud";
description: string;
/** Shown when this provider is not available on current OS */
platforms?: Array<"mac" | "win" | "linux" | "other">;
}
export const TTS_PROVIDER_ORDER = [
"system",
"piper",
"openai",
] as const;
export type TtsProviderId = (typeof TTS_PROVIDER_ORDER)[number];
export const TTS_PROVIDER_PRESETS: Record<TtsProviderId, TtsProviderPreset> = {
system: {
id: "system",
label: "本地 · 系统语音",
kind: "local",
description: "macOS say / Windows SAPI,零配置、完全离线",
platforms: ["mac", "win"],
},
piper: {
id: "piper",
label: "本地 · Piper",
kind: "local",
description: "离线神经 TTS,需安装 piper 并配置模型路径",
platforms: ["mac", "win", "linux", "other"],
},
openai: {
id: "openai",
label: "云端 · OpenAI 兼容",
kind: "cloud",
description: "OpenAI / 兼容端点 TTS(待接入)",
},
};
export function isTtsProviderAvailable(preset: TtsProviderPreset): boolean {
if (!preset.platforms) return true;
return preset.platforms.includes(getHostPlatform());
}
export function getDefaultTtsProviderId(): TtsProviderId {
if (isMac() || isWindows()) return "system";
return "piper";
}
export function createTtsProvider(id: string): TtsProvider {
switch (id) {
case "system":
if (isMac()) return createMacSayProvider();
if (isWindows()) return createWindowsSapiProvider();
throw new Error("系统语音在当前平台不可用,请选择 Piper 或云端 TTS");
case "piper":
return createPiperProvider();
case "openai":
throw new Error("云端 TTS 尚未接入,请先用本地系统语音或 Piper");
default:
throw new Error(`未知 TTS Provider: ${id}`);
}
}
@@ -0,0 +1,40 @@
export interface WordTimestamp {
text: string;
startMs: number;
endMs: number;
}
export interface TtsSynthesizeOptions {
text: string;
/** Full output path including extension (.wav / .mp3) */
outputPath: string;
voice?: string;
lang?: "zh" | "en";
}
export interface TtsResult {
audioPath: string;
timestamps: WordTimestamp[];
durationMs: number;
format: "wav" | "mp3" | "aiff";
}
export interface TtsProvider {
id: string;
label: string;
kind: "local" | "cloud";
supportsTimestamps: boolean;
/** Whether user must configure an executable or model path */
needsPath?: boolean;
synthesize(options: TtsSynthesizeOptions): Promise<TtsResult>;
}
export class TtsError extends Error {
constructor(
message: string,
public code: "config" | "runtime" | "unsupported",
) {
super(message);
this.name = "TtsError";
}
}
@@ -0,0 +1,74 @@
import { findPdfAttachment } from "../../../pdf/extractor";
import type { PaperAttachmentContext } from "../../domain/types";
import {
buildCacheKey,
computeFileHash,
paperIdFromCacheKey,
} from "../pdf/fileHash";
function attachmentFilePath(attachment: Zotero.Item): string {
const path = attachment.getFilePath?.() as string | false;
if (!path) {
throw new Error("PDF attachment has no local file path");
}
return path;
}
export async function resolvePaperAttachment(
item: Zotero.Item,
): Promise<PaperAttachmentContext> {
const attachment = findPdfAttachment(item);
if (!attachment) {
throw new Error("NO_PDF");
}
const filePath = attachmentFilePath(attachment);
const exists = await IOUtils.exists(filePath);
if (!exists) {
throw new Error("PDF_FILE_MISSING");
}
const fileHash = await computeFileHash(filePath);
const attachmentId = String(attachment.id);
const cacheKey = buildCacheKey(attachmentId, fileHash);
return {
item,
attachment,
filePath,
fileHash,
cacheKey,
};
}
export function getRegularItem(item: Zotero.Item): Zotero.Item {
if (item.isRegularItem()) return item;
if (item.parentItemID) {
const parent = Zotero.Items.get(item.parentItemID);
if (parent) return parent;
}
return item;
}
export async function openPdfAtPage(
item: Zotero.Item,
page: number,
): Promise<void> {
const attachment = findPdfAttachment(item);
if (!attachment) return;
try {
const reader = await (Zotero.Reader as any).open(attachment.id);
if (reader?.navigate) {
await reader.navigate({ pageNumber: page });
return;
}
if (reader?.setPageIndex) {
reader.setPageIndex(Math.max(0, page - 1));
}
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] openPdfAtPage failed", e);
}
}
export { paperIdFromCacheKey };
+533
View File
@@ -0,0 +1,533 @@
import { Headphones, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide";
import type { LectureStatus, PaperCache } from "../domain/types";
import { prepareLecture } from "../application/prepareLecture";
import {
createPlayLecture,
type PlayLectureHandle,
} from "../application/playLecture";
import { lectureEvents } from "../infrastructure/events";
import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm";
import { pathToFileUrl } from "../infrastructure/platform/paths";
import { getHostPlatform } from "../infrastructure/platform/os";
import {
ensureSampleDir,
lectureSamplePath,
} from "../infrastructure/storage/paths";
import {
isStep1Ready,
loadLectureData,
type StoredBeat,
} from "../infrastructure/storage/lectureStore";
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
import { paperRepository } from "../infrastructure/storage/repository";
import {
getTtsProviderLabel,
getTtsRuntimeConfig,
synthesizeSample,
} from "../infrastructure/tts/client";
import { TtsError } from "../infrastructure/tts/types";
import {
getRegularItem,
resolvePaperAttachment,
} from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { getString } from "../../../utils/locale";
import { createLucideIcon } from "../../../utils/icons";
export class LecturePaneView {
private doc: Document;
private body: HTMLElement;
private item: Zotero.Item;
private unsubscribers: Array<() => void> = [];
private paper?: PaperCache;
private preparing = false;
private ttsTesting = false;
private audioEl?: HTMLAudioElement;
private player?: PlayLectureHandle;
private activeBeatId?: string;
private statusEl!: HTMLElement;
private statusTextEl!: HTMLElement;
private ttsMetaEl!: HTMLElement;
private actionBtn!: HTMLButtonElement;
private ttsTestBtn!: HTMLButtonElement;
private hintEl!: HTMLElement;
private playerSection!: HTMLElement;
private beatListEl!: HTMLElement;
private playBtn!: HTMLButtonElement;
private pauseBtn!: HTMLButtonElement;
private prevBtn!: HTMLButtonElement;
private nextBtn!: 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", "chatpapers-lecture-root");
const parent = getRegularItem(this.item);
const title = String(parent.getField("title") || "ChatPapers");
const header = this.el("div", "chatpapers-header");
const brand = this.el("div", "chatpapers-brand");
brand.append(
createLucideIcon(this.doc, Headphones, {
size: 18,
className: "chatpapers-brand-icon",
}),
this.el("div", "chatpapers-brand-text", "ChatPapers"),
);
header.append(
brand,
this.el("div", "chatpapers-title", title),
this.el("div", "chatpapers-meta", getString("lecture-mode-label")),
);
const empty = this.el("div", "chatpapers-empty chatpapers-lecture-empty");
empty.append(
createLucideIcon(this.doc, Sparkles, {
size: 28,
className: "chatpapers-empty-icon",
}),
this.el("div", "chatpapers-empty-title", getString("lecture-empty-title")),
this.el(
"div",
"chatpapers-empty-desc",
getString("lecture-empty-desc"),
),
);
this.statusEl = this.el("div", "chatpapers-lecture-status");
this.statusTextEl = this.el("span", "chatpapers-lecture-status-text");
this.statusEl.append(this.statusTextEl);
this.ttsMetaEl = this.el("div", "chatpapers-lecture-tts-meta");
this.updateTtsMeta();
this.actionBtn = this.doc.createElement("button");
this.actionBtn.className = "chatpapers-lecture-primary";
this.actionBtn.type = "button";
this.actionBtn.addEventListener("click", () => void this.onStartPrepare());
this.ttsTestBtn = this.doc.createElement("button");
this.ttsTestBtn.className = "chatpapers-lecture-secondary";
this.ttsTestBtn.type = "button";
this.ttsTestBtn.append(
createLucideIcon(this.doc, Volume2, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(getString("lecture-tts-test")),
);
this.ttsTestBtn.addEventListener("click", () => void this.onTtsTest());
this.playerSection = this.el("div", "chatpapers-lecture-player");
this.playerSection.hidden = true;
const playerTitle = this.el(
"div",
"chatpapers-lecture-player-title",
getString("lecture-beat-list-title"),
);
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list");
const playerControls = this.el("div", "chatpapers-lecture-player-controls");
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () =>
void this.player?.prev(),
);
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () =>
void this.player?.play(),
);
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () =>
this.player?.pause(),
);
this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () =>
void this.player?.next(),
);
this.pauseBtn.hidden = true;
playerControls.append(this.prevBtn, this.playBtn, this.pauseBtn, this.nextBtn);
this.playerSection.append(playerTitle, this.beatListEl, playerControls);
this.audioEl = this.doc.createElement("audio");
this.audioEl.className = "chatpapers-lecture-audio";
this.audioEl.controls = true;
this.audioEl.preload = "none";
this.hintEl = this.el("div", "chatpapers-lecture-hint");
this.updatePlatformHint();
const actions = this.el("div", "chatpapers-lecture-actions");
actions.append(this.actionBtn, this.ttsTestBtn);
this.body.append(
header,
empty,
this.statusEl,
this.ttsMetaEl,
actions,
this.playerSection,
this.audioEl,
this.hintEl,
);
this.updateActionButton();
this.bindEvents();
await this.refreshFromStore();
}
destroy(): void {
for (const off of this.unsubscribers) off();
this.unsubscribers = [];
this.player?.destroy();
this.player = undefined;
this.audioEl?.pause();
this.body.replaceChildren();
this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root");
}
private cacheKey?: string;
private bindEvents(): void {
this.unsubscribers.push(
lectureEvents.on("lecture:status", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
this.setStatus(d.lectureStatus as LectureStatus, d.message);
if (this.paper) this.paper.lectureStatus = d.lectureStatus as LectureStatus;
if (d.lectureStatus === "ready") void this.refreshPlayer();
}),
lectureEvents.on("parse:progress", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
this.setStatus(this.paper?.lectureStatus ?? "parsing", d.message);
}),
lectureEvents.on("lecture:failed", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
this.setStatus("failed", d.error);
this.preparing = false;
this.actionBtn.disabled = false;
}),
lectureEvents.on("lecture:beats_ready", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer();
}),
lectureEvents.on("tts:unit_ready", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer();
}),
);
}
private shouldHandleEvent(paperId: string): boolean {
if (this.preparing) return this.paper?.id === paperId || !this.paper;
return this.paper?.id === paperId;
}
private makeIconBtn(
labelKey: Parameters<typeof getString>[0],
icon: typeof Play,
onClick: () => void,
): HTMLButtonElement {
const btn = this.doc.createElement("button");
btn.type = "button";
btn.className = "chatpapers-lecture-player-btn";
btn.title = getString(labelKey);
btn.append(
createLucideIcon(this.doc, icon, {
size: 16,
className: "chatpapers-lecture-btn-icon",
}),
);
btn.addEventListener("click", onClick);
return btn;
}
private updateTtsMeta(): void {
const cfg = getTtsRuntimeConfig();
const label = getTtsProviderLabel(cfg.provider);
this.ttsMetaEl.textContent = getString("lecture-tts-provider", {
args: { provider: label },
});
}
private updateActionButton(): void {
this.actionBtn.replaceChildren();
const isReady =
this.paper?.lectureStatus === "ready" && this.playerSection && !this.playerSection.hidden;
const label = isReady
? getString("lecture-reprepare")
: getString("lecture-start");
this.actionBtn.append(
createLucideIcon(this.doc, Play, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(label),
);
}
private async refreshFromStore(): Promise<void> {
try {
const ctx = await resolvePaperAttachment(this.item);
this.cacheKey = ctx.cacheKey;
const cached = await paperRepository.getByCacheKey(ctx.cacheKey);
if (cached) {
this.paper = cached;
const msg = await this.statusMessage(cached, ctx.cacheKey);
this.setStatus(cached.lectureStatus, msg);
} else {
this.setStatus("idle", getString("lecture-status-idle"));
}
await this.refreshPlayer();
} catch (e) {
const msg =
e instanceof Error && e.message === "NO_PDF"
? getString("lecture-no-pdf")
: getString("lecture-attach-error");
this.setStatus("failed", msg);
this.actionBtn.disabled = true;
this.ttsTestBtn.disabled = true;
}
}
private async refreshPlayer(): Promise<void> {
if (!this.cacheKey || !this.audioEl) return;
const data = await loadLectureData(this.cacheKey);
const beats = data?.beats ?? [];
const canPlay = isStep1Ready(data);
this.playerSection.hidden = !beats.length;
this.renderBeatList(beats);
if (!this.player) {
this.player = createPlayLecture({
audio: this.audioEl,
beats,
onBeatChange: (id) => {
this.activeBeatId = id;
this.highlightBeat(id);
},
onPlayingChange: (playing) => {
this.playBtn.hidden = playing;
this.pauseBtn.hidden = !playing;
},
onComplete: () => {
this.setStatus("ready", getString("lecture-play-complete"));
},
});
} else {
this.player.setBeats(beats);
}
this.updateActionButton();
if (canPlay && this.paper?.lectureStatus === "ready") {
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
if (!this.preparing) {
this.setStatus(
"ready",
getString("lecture-status-ready-step1", {
args: { count: readyCount },
}),
);
}
}
}
private renderBeatList(beats: StoredBeat[]): void {
this.beatListEl.replaceChildren();
for (const beat of beats) {
const row = this.doc.createElement("button") as HTMLButtonElement;
row.type = "button";
row.className = "chatpapers-lecture-beat-item";
row.dataset.beatId = beat.id;
if (beat.id === this.activeBeatId) {
row.classList.add("is-active");
}
if (beat.ttsStatus !== "ready") {
row.classList.add("is-pending");
row.disabled = true;
}
const meta = this.el("div", "chatpapers-lecture-beat-meta");
meta.append(
this.el(
"span",
"chatpapers-lecture-beat-type",
BEAT_TYPE_LABELS[beat.type] || beat.type,
),
this.el(
"span",
"chatpapers-lecture-beat-title",
beat.title || beat.id,
),
);
const status = this.el("span", "chatpapers-lecture-beat-status");
if (beat.ttsStatus === "ready") {
status.textContent = "✓";
} else if (beat.ttsStatus === "failed") {
status.textContent = getString("lecture-beat-tts-failed");
} else {
status.textContent = getString("lecture-beat-tts-pending");
}
row.append(meta, status);
row.addEventListener("click", () => {
void this.player?.jumpToBeatId(beat.id);
});
this.beatListEl.append(row);
}
}
private highlightBeat(beatId?: string): void {
this.activeBeatId = beatId;
for (const node of this.beatListEl.querySelectorAll(
".chatpapers-lecture-beat-item",
)) {
const el = node as HTMLElement;
el.classList.toggle("is-active", el.dataset.beatId === beatId);
}
}
private async statusMessage(
paper: PaperCache,
cacheKey?: string,
): Promise<string> {
switch (paper.lectureStatus) {
case "ready": {
if (cacheKey) {
const data = await loadLectureData(cacheKey);
if (isStep1Ready(data)) {
const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length;
return getString("lecture-status-ready-step1", { args: { count } });
}
if (data?.summary) {
return `${getString("lecture-status-ready-phase1")}\n${formatSummaryForDisplay(data.summary)}`;
}
}
return getString("lecture-status-ready");
}
case "failed":
return getString("lecture-status-failed");
case "idle":
return getString("lecture-status-idle");
default:
return getString("lecture-status-preparing");
}
}
private setStatus(status: LectureStatus | "idle", message?: string): void {
this.statusEl.dataset.status = status;
this.statusTextEl.textContent =
message || getString("lecture-status-idle");
const busy = [
"parsing",
"summarizing",
"beats_generating",
"paragraphs_generating",
"tts_generating",
].includes(status);
this.actionBtn.disabled = this.preparing || busy;
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
}
private updatePlatformHint(): void {
const platform = getHostPlatform();
const key =
platform === "win"
? "lecture-platform-hint-win"
: platform === "mac"
? "lecture-platform-hint-mac"
: "lecture-platform-hint-other";
this.hintEl.textContent = getString(key);
}
private async onStartPrepare(): Promise<void> {
if (this.preparing) return;
this.preparing = true;
this.actionBtn.disabled = true;
this.ttsTestBtn.disabled = true;
this.setStatus("parsing", getString("lecture-status-preparing"));
try {
const ctx = await resolvePaperAttachment(this.item);
this.cacheKey = ctx.cacheKey;
const existing = await paperRepository.getByCacheKey(ctx.cacheKey);
if (existing) {
this.paper = existing;
} else {
const parent = getRegularItem(this.item);
this.paper = {
id: paperIdFromCacheKey(ctx.cacheKey),
attachmentId: String(ctx.attachment.id),
itemId: String(parent.id),
fileHash: ctx.fileHash,
filePath: ctx.filePath,
title: String(parent.getField("title") || ""),
parseStatus: "pending",
lectureStatus: "idle",
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
this.paper = await prepareLecture.start(this.item);
const msg = await this.statusMessage(this.paper, ctx.cacheKey);
this.setStatus(this.paper.lectureStatus, msg);
await this.refreshPlayer();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.setStatus("failed", msg);
} finally {
this.preparing = false;
this.ttsTestBtn.disabled = false;
if (this.paper?.lectureStatus !== "parsing") {
this.actionBtn.disabled = false;
}
this.updateActionButton();
}
}
private async onTtsTest(): Promise<void> {
if (this.ttsTesting) return;
this.ttsTesting = true;
this.ttsTestBtn.disabled = true;
this.setStatus("idle", getString("lecture-tts-testing"));
try {
await ensureSampleDir();
const outPath = lectureSamplePath("tts-test.wav");
const result = await synthesizeSample(outPath);
if (!this.audioEl) return;
this.audioEl.src = pathToFileUrl(result.audioPath);
this.audioEl.load();
await this.audioEl.play();
this.setStatus("idle", getString("lecture-tts-test-done"));
} catch (e) {
const msg =
e instanceof TtsError
? e.message
: e instanceof Error
? e.message
: String(e);
this.setStatus("failed", msg);
} finally {
this.ttsTesting = false;
this.ttsTestBtn.disabled = false;
}
}
private el(tag: string, className?: string, text?: string): HTMLElement {
const node = this.doc.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
}
@@ -0,0 +1,65 @@
import { config } from "../../../../package.json";
import { getLocaleID, getString } from "../../../utils/locale";
import { findPdfAttachment } from "../../pdf/extractor";
import { ensureChatPapersStyles } from "../../ui/readerPane";
import { LecturePaneView } from "./lecturePane";
const views = new WeakMap<HTMLElement, LecturePaneView>();
function iconURL(file: string) {
return `chrome://${config.addonRef}/content/icons/${file}`;
}
export function registerLecturePane() {
Zotero.ItemPaneManager.registerSection({
paneID: "chatpapers-lecture",
pluginID: config.addonID,
header: {
l10nID: getLocaleID("item-section-lecture-head"),
icon: iconURL("lecture.svg"),
},
sidenav: {
l10nID: getLocaleID("item-section-lecture-sidenav"),
icon: iconURL("lecture.svg"),
},
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureChatPapersStyles(doc);
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
views.delete(body);
},
onItemChange: ({ item, setEnabled }) => {
setEnabled(Boolean(item && findPdfAttachment(item)));
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("lecture-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 LecturePaneView(doc, body, item);
views.set(body, view);
try {
await view.mount();
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] mount failed", e);
body.replaceChildren();
const err = doc.createElement("div");
err.className = "chatpapers-lecture-error";
err.textContent = getString("lecture-mount-error");
body.append(err);
}
},
});
}
+6 -1
View File
@@ -149,19 +149,24 @@ export async function chatStream(options: {
messages: ChatMessage[];
onDelta: (text: string) => void;
signal?: AbortSignalLike;
/** OpenAI-compatible response_format json_object */
jsonMode?: boolean;
}): Promise<string> {
const cfg = getRuntimeConfig();
assertConfig(cfg);
throwIfAborted(options.signal);
const url = joinUrl(cfg.baseUrl, "/chat/completions");
const body = {
const body: Record<string, unknown> = {
model: cfg.model,
messages: options.messages,
stream: true,
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
};
if (options.jsonMode) {
body.response_format = { type: "json_object" };
}
const doFetch = resolveFetch();
let res: Response;
+124
View File
@@ -0,0 +1,124 @@
import { LlmError } from "./types";
/** Remove markdown fences and leading/trailing prose wrappers. */
export function stripMarkdownFence(text: string): string {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) return fenced[1].trim();
return text.trim();
}
/** Extract first balanced `{...}` or `[...]` substring. */
export function extractBalancedJson(
text: string,
open: "{" | "[",
): string | undefined {
const start = text.indexOf(open);
if (start < 0) return undefined;
const close = open === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inString) {
if (escape) escape = false;
else if (c === "\\") escape = true;
else if (c === '"') inString = false;
continue;
}
if (c === '"') {
inString = true;
continue;
}
if (c === open) depth++;
if (c === close) {
depth--;
if (depth === 0) return text.slice(start, i + 1);
}
}
return undefined;
}
/** Fix common LLM JSON mistakes (not a full repair engine). */
export function repairJsonText(json: string): string {
return json
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/[\u2018\u2019]/g, "'")
.replace(/,\s*([}\]])/g, "$1")
.replace(/\r\n/g, "\n");
}
function tryParse<T>(text: string): T | undefined {
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
}
/**
* Best-effort JSON parse for LLM outputs.
* Tries: direct → fenced → balanced extract → repaired variants.
*/
export function parseJsonLoose<T>(raw: string): T {
const trimmed = stripMarkdownFence(raw);
const attempts: string[] = [trimmed];
const obj = extractBalancedJson(trimmed, "{");
const arr = extractBalancedJson(trimmed, "[");
if (obj) attempts.push(obj, repairJsonText(obj));
if (arr) attempts.push(arr, repairJsonText(arr));
attempts.push(repairJsonText(trimmed));
for (const candidate of attempts) {
const parsed = tryParse<T>(candidate);
if (parsed !== undefined) return parsed;
}
throw new LlmError(
"provider",
"模型返回的内容无法解析为 JSON。可尝试更换模型,或降低 temperature。",
);
}
/** Parse `key: value` / `keyvalue` labeled blocks (Chinese labels). */
export function parseLabeledFields(
text: string,
keys: Array<{ field: string; labels: string[] }>,
): Record<string, string> {
const out: Record<string, string> = {};
const lines = text.split(/\n/);
let currentField: string | undefined;
let buffer: string[] = [];
const flush = () => {
if (currentField && buffer.length) {
out[currentField] = buffer.join(" ").trim();
}
buffer = [];
};
for (const line of lines) {
let matched = false;
for (const { field, labels } of keys) {
for (const label of labels) {
const re = new RegExp(`^\\s*${label}\\s*[:]\\s*(.*)$`, "i");
const m = line.match(re);
if (m) {
flush();
currentField = field;
if (m[1]?.trim()) buffer.push(m[1].trim());
matched = true;
break;
}
}
if (matched) break;
}
if (!matched && currentField && line.trim()) {
buffer.push(line.trim());
}
}
flush();
return out;
}
+87
View File
@@ -0,0 +1,87 @@
import { getPref, setPref } from "../../utils/prefs";
import { getString } from "../../utils/locale";
import { getHostPlatform } from "../lecture/infrastructure/platform/os";
import {
TTS_PROVIDER_ORDER,
TTS_PROVIDER_PRESETS,
getDefaultTtsProviderId,
isTtsProviderAvailable,
} from "../lecture/infrastructure/tts/providers";
export function registerLecturePrefsScripts(win: Window) {
const doc = win.document;
const providerSelect = doc.getElementById(
"chatpapers-pref-tts-provider",
) as HTMLSelectElement | null;
const voiceInput = doc.getElementById(
"chatpapers-pref-tts-voice",
) as HTMLInputElement | null;
const piperPathInput = doc.getElementById(
"chatpapers-pref-piper-path",
) as HTMLInputElement | null;
const piperModelInput = doc.getElementById(
"chatpapers-pref-piper-model",
) as HTMLInputElement | null;
const mineruPathInput = doc.getElementById(
"chatpapers-pref-mineru-path",
) as HTMLInputElement | null;
const hint = doc.getElementById("chatpapers-pref-tts-hint");
const piperBox = doc.getElementById("chatpapers-pref-piper-box");
if (providerSelect && providerSelect.options.length === 0) {
for (const id of TTS_PROVIDER_ORDER) {
const preset = TTS_PROVIDER_PRESETS[id];
if (!isTtsProviderAvailable(preset)) continue;
const opt = doc.createElement("option");
opt.value = id;
opt.textContent = preset.label;
providerSelect.appendChild(opt);
}
const current = getPref("ttsProvider") || getDefaultTtsProviderId();
providerSelect.value = current;
if (!getPref("ttsProvider")) setPref("ttsProvider", current);
}
const updateHint = () => {
const id = providerSelect?.value || getPref("ttsProvider") || "";
const preset = TTS_PROVIDER_PRESETS[id as keyof typeof TTS_PROVIDER_PRESETS];
if (hint) {
hint.textContent = preset?.description || getString("prefs-tts-hint-empty");
}
if (piperBox) {
(piperBox as HTMLElement).hidden = id !== "piper";
}
if (voiceInput) {
const platform = getHostPlatform();
voiceInput.placeholder =
platform === "mac"
? "Ting-Ting(中文)/ Samantha(英文)"
: platform === "win"
? "Microsoft Huihui Desktop"
: "";
}
};
providerSelect?.addEventListener("change", () => {
setPref("ttsProvider", providerSelect.value);
updateHint();
});
voiceInput?.addEventListener("change", () => {
setPref("ttsVoice", voiceInput.value);
});
piperPathInput?.addEventListener("change", () => {
setPref("piperPath", piperPathInput.value);
});
piperModelInput?.addEventListener("change", () => {
setPref("piperModelPath", piperModelInput.value);
});
mineruPathInput?.addEventListener("change", () => {
setPref("mineruPath", mineruPathInput.value);
});
updateHint();
}
+3
View File
@@ -9,6 +9,8 @@ import {
} from "../llm/providers";
import { getString } from "../../utils/locale";
import { registerLecturePrefsScripts } from "./lecturePrefs";
export function registerPrefsScripts(win: Window) {
const doc = win.document;
const providerSelect = doc.getElementById(
@@ -99,4 +101,5 @@ export function registerPrefsScripts(win: Window) {
});
updateHint();
registerLecturePrefsScripts(win);
}
+5 -1
View File
@@ -64,7 +64,7 @@ export function registerPrefs() {
});
}
function ensureStyles(doc: Document) {
export function ensureChatPapersStyles(doc: Document) {
const id = "chatpapers-styles";
if (doc.getElementById(id)) return;
const link = doc.createElement("link");
@@ -74,3 +74,7 @@ function ensureStyles(doc: Document) {
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
doc.documentElement?.appendChild(link);
}
function ensureStyles(doc: Document) {
ensureChatPapersStyles(doc);
}