2026-03-19 01:12:17 +08:00
|
|
|
|
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);
|
2026-03-20 16:36:42 +08:00
|
|
|
|
await executeSql(
|
|
|
|
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS uq_segment_outbox_voice_session_segment
|
|
|
|
|
|
ON segment_outbox(voice_session_id, segment_index)`,
|
|
|
|
|
|
);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
export interface VoicePlaybackRow {
|
|
|
|
|
|
voiceSessionId: string;
|
|
|
|
|
|
fileUri: string;
|
|
|
|
|
|
durationMs: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
/**
|
2026-03-20 16:36:42 +08:00
|
|
|
|
* 本地语音分段:outbox(pending→sent)与可回放元数据共用同一张表。
|
|
|
|
|
|
* 音频文件在文件系统;`status=sent` 行保留用于按 voice_session_id 关联 REST 历史。
|
2026-03-19 01:12:17 +08:00
|
|
|
|
*/
|
2026-03-20 16:36:42 +08:00
|
|
|
|
export const voiceSegmentStore = {
|
2026-03-19 01:12:17 +08:00
|
|
|
|
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;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
/** 发送成功后写入(或覆盖)同一条 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,
|
|
|
|
|
|
}));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
async clearConversation(conversationId: string): Promise<void> {
|
2026-03-19 01:12:17 +08:00
|
|
|
|
await ensureTable();
|
|
|
|
|
|
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-20 16:36:42 +08:00
|
|
|
|
/** @internal 测试用 */
|
2026-03-19 01:12:17 +08:00
|
|
|
|
_resetForTest(): void {
|
|
|
|
|
|
initialized = false;
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|