增加语音对话功能
This commit is contained in:
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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` / `key:value` 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user