- 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
import { executeSql, querySql } from '@/core/storage/sqlite';
|
||
|
||
import type { SegmentOutboxEntry, SegmentOutboxStatus } from './types';
|
||
|
||
const CREATE_TABLE_SQL = `
|
||
CREATE TABLE IF NOT EXISTS segment_outbox (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
conversation_id TEXT NOT NULL,
|
||
voice_session_id TEXT NOT NULL,
|
||
segment_index INTEGER NOT NULL,
|
||
file_uri TEXT NOT NULL,
|
||
duration_ms INTEGER NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
`;
|
||
|
||
let initialized = false;
|
||
|
||
async function ensureTable(): Promise<void> {
|
||
if (initialized) return;
|
||
await executeSql(CREATE_TABLE_SQL);
|
||
await executeSql(
|
||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_segment_outbox_voice_session_segment
|
||
ON segment_outbox(voice_session_id, segment_index)`,
|
||
);
|
||
initialized = true;
|
||
}
|
||
|
||
function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
|
||
return {
|
||
id: row.id as number,
|
||
conversationId: row.conversation_id as string,
|
||
voiceSessionId: row.voice_session_id as string,
|
||
segmentIndex: row.segment_index as number,
|
||
fileUri: row.file_uri as string,
|
||
durationMs: row.duration_ms as number,
|
||
status: row.status as SegmentOutboxStatus,
|
||
retryCount: row.retry_count as number,
|
||
createdAt: row.created_at as number,
|
||
};
|
||
}
|
||
|
||
export interface VoicePlaybackRow {
|
||
voiceSessionId: string;
|
||
fileUri: string;
|
||
durationMs: number;
|
||
}
|
||
|
||
/**
|
||
* 本地语音分段:outbox(pending→sent)与可回放元数据共用同一张表。
|
||
* 音频文件在文件系统;`status=sent` 行保留用于按 voice_session_id 关联 REST 历史。
|
||
*/
|
||
export const voiceSegmentStore = {
|
||
async enqueue(
|
||
entry: Omit<
|
||
SegmentOutboxEntry,
|
||
'id' | 'status' | 'retryCount' | 'createdAt'
|
||
>,
|
||
): Promise<number> {
|
||
await ensureTable();
|
||
const result = await executeSql(
|
||
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
|
||
[
|
||
entry.conversationId,
|
||
entry.voiceSessionId,
|
||
entry.segmentIndex,
|
||
entry.fileUri,
|
||
entry.durationMs,
|
||
Date.now(),
|
||
],
|
||
);
|
||
return result.lastInsertRowId;
|
||
},
|
||
|
||
/** 发送成功后写入(或覆盖)同一条 voice+segment,用于回放与 outbox 终态统一 */
|
||
async recordSentSegment(entry: {
|
||
conversationId: string;
|
||
voiceSessionId: string;
|
||
segmentIndex?: number;
|
||
fileUri: string;
|
||
durationMs: number;
|
||
}): Promise<void> {
|
||
await ensureTable();
|
||
const segmentIndex = entry.segmentIndex ?? 0;
|
||
const now = Date.now();
|
||
await executeSql(
|
||
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 'sent', 0, ?)
|
||
ON CONFLICT(voice_session_id, segment_index) DO UPDATE SET
|
||
conversation_id = excluded.conversation_id,
|
||
file_uri = excluded.file_uri,
|
||
duration_ms = excluded.duration_ms,
|
||
status = 'sent',
|
||
retry_count = 0`,
|
||
[
|
||
entry.conversationId,
|
||
entry.voiceSessionId,
|
||
segmentIndex,
|
||
entry.fileUri,
|
||
entry.durationMs,
|
||
now,
|
||
],
|
||
);
|
||
},
|
||
|
||
async listPlaybackForConversation(
|
||
conversationId: string,
|
||
): Promise<VoicePlaybackRow[]> {
|
||
await ensureTable();
|
||
const rows = await querySql<Record<string, unknown>>(
|
||
`SELECT voice_session_id AS voiceSessionId, file_uri AS fileUri, duration_ms AS durationMs
|
||
FROM segment_outbox WHERE conversation_id = ? AND status = 'sent'`,
|
||
[conversationId],
|
||
);
|
||
return rows.map((r) => ({
|
||
voiceSessionId: r.voiceSessionId as string,
|
||
fileUri: r.fileUri as string,
|
||
durationMs: r.durationMs as number,
|
||
}));
|
||
},
|
||
|
||
async getPending(conversationId?: string): Promise<SegmentOutboxEntry[]> {
|
||
await ensureTable();
|
||
const sql = conversationId
|
||
? `SELECT * FROM segment_outbox WHERE status = 'pending' AND conversation_id = ? ORDER BY created_at ASC`
|
||
: `SELECT * FROM segment_outbox WHERE status = 'pending' ORDER BY created_at ASC`;
|
||
const params = conversationId ? [conversationId] : [];
|
||
const rows = await querySql<Record<string, unknown>>(sql, params);
|
||
return rows.map(mapRow);
|
||
},
|
||
|
||
async markSending(id: number): Promise<void> {
|
||
await ensureTable();
|
||
await executeSql(
|
||
`UPDATE segment_outbox SET status = 'sending' WHERE id = ?`,
|
||
[id],
|
||
);
|
||
},
|
||
|
||
async markSent(id: number): Promise<void> {
|
||
await ensureTable();
|
||
await executeSql(`UPDATE segment_outbox SET status = 'sent' WHERE id = ?`, [
|
||
id,
|
||
]);
|
||
},
|
||
|
||
async markFailed(id: number): Promise<void> {
|
||
await ensureTable();
|
||
await executeSql(
|
||
`UPDATE segment_outbox SET status = 'failed', retry_count = retry_count + 1 WHERE id = ?`,
|
||
[id],
|
||
);
|
||
},
|
||
|
||
async resetFailed(conversationId?: string): Promise<void> {
|
||
await ensureTable();
|
||
const sql = conversationId
|
||
? `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed' AND conversation_id = ?`
|
||
: `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed'`;
|
||
const params = conversationId ? [conversationId] : [];
|
||
await executeSql(sql, params);
|
||
},
|
||
|
||
async clearConversation(conversationId: string): Promise<void> {
|
||
await ensureTable();
|
||
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
|
||
conversationId,
|
||
]);
|
||
},
|
||
|
||
/** @internal 测试用 */
|
||
_resetForTest(): void {
|
||
initialized = false;
|
||
},
|
||
} as const;
|