增加语音对话功能
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user