增加语音对话功能

This commit is contained in:
yhy
2026-08-29 22:13:55 +08:00
parent 1ffa069151
commit 3a2438f566
51 changed files with 6207 additions and 3 deletions
@@ -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;