修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致

新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
This commit is contained in:
Kevin
2026-03-20 16:36:42 +08:00
parent 7317bf10cd
commit 8af37e5e8e
65 changed files with 1704 additions and 504 deletions

View File

@@ -8,8 +8,11 @@ import type { PlaybackItem, PlayerStatus } from '../types';
interface UsePlayerResult {
status: PlayerStatus;
queueLength: number;
enqueueTtsAudio: (audioBase64: string) => void;
/** Current playback source URI (file, https, or data URL). */
currentSource: string | null;
enqueue: (item: PlaybackItem) => void;
/** Replace queue and play this item (e.g. user voice bubble vs other sources). */
enqueueExclusive: (item: PlaybackItem) => Promise<void>;
stop: () => void;
}
@@ -29,8 +32,12 @@ export function usePlayer(): UsePlayerResult {
const isPlayingRef = useRef(false);
const wasBlockedByRecorderRef = useRef(false);
const isPlayNextInProgressRef = useRef(false);
/** 同步反映「当前是否正在播放某条 URI」enqueue 不能依赖 state否则 await stop() 后仍为陈旧闭包。 */
const playbackActiveUriRef = useRef<string | null>(null);
/** 当前 source 是否已进入过 playing=true避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */
const trackHasPlayedRef = useRef(false);
const player = useAudioPlayer(currentSource);
const player = useAudioPlayer(currentSource, { downloadFirst: false });
const playerStatus = useAudioPlayerStatus(player);
// Start playback when a new source is set
@@ -46,6 +53,7 @@ export function usePlayer(): UsePlayerResult {
isPlayNextInProgressRef.current = true;
try {
if (queueRef.current.length === 0) {
playbackActiveUriRef.current = null;
setCurrentSource(null);
setStatus('idle');
setQueueLength(0);
@@ -64,20 +72,33 @@ export function usePlayer(): UsePlayerResult {
const next = queueRef.current.shift()!;
setQueueLength(queueRef.current.length);
setStatus('playing');
trackHasPlayedRef.current = false;
playbackActiveUriRef.current = next.uri;
setCurrentSource(next.uri);
} finally {
isPlayNextInProgressRef.current = false;
}
}, []);
// Detect playback completion → advance queue
useEffect(() => {
if (playerStatus.playing) {
trackHasPlayedRef.current = true;
}
}, [playerStatus.playing]);
// Detect playback completion → advance queue必须曾 playing避免换源瞬间沿用上一条的 duration/currentTime
useEffect(() => {
if (!currentSource || !isPlayingRef.current) return;
const { playing, currentTime, duration } = playerStatus;
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
const finished =
trackHasPlayedRef.current &&
!playing &&
duration > 0 &&
currentTime >= duration - 0.05;
if (finished) {
trackHasPlayedRef.current = false;
isPlayingRef.current = false;
playNext();
}
@@ -107,19 +128,31 @@ export function usePlayer(): UsePlayerResult {
queueRef.current.push(item);
setQueueLength(queueRef.current.length);
if (status === 'idle' && !currentSource) {
const shouldKick =
queueRef.current.length === 1 && playbackActiveUriRef.current === null;
if (shouldKick) {
await playNext();
}
},
[status, currentSource, playNext],
[playNext],
);
const enqueueTtsAudio = useCallback(
(audioBase64: string) => {
const uri = `data:audio/mp3;base64,${audioBase64}`;
enqueue({ uri, label: 'TTS' });
const enqueueExclusive = useCallback(
async (item: PlaybackItem) => {
queueRef.current = [item];
setQueueLength(1);
isPlayingRef.current = false;
if (player) {
player.pause();
}
playbackActiveUriRef.current = null;
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
await playNext();
},
[enqueue],
[player, playNext],
);
const stop = useCallback(async () => {
@@ -131,10 +164,18 @@ export function usePlayer(): UsePlayerResult {
player.pause();
}
playbackActiveUriRef.current = null;
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
}, [player]);
return { status, queueLength, enqueueTtsAudio, enqueue, stop };
return {
status,
queueLength,
currentSource,
enqueue,
enqueueExclusive,
stop,
};
}

View File

@@ -11,7 +11,7 @@ export interface SegmenterConfig {
fixedDurationMs: number;
}
// ─── Segment outbox ───
// ─── 本地语音分段outbox + 已发送可回放元数据,见 voice-segment-store───
export type SegmentOutboxStatus = 'pending' | 'sending' | 'sent' | 'failed';

View File

@@ -18,9 +18,29 @@ const CREATE_TABLE_SQL = `
let initialized = false;
async function migrateLegacyVoiceMessageLocal(): Promise<void> {
const rows = await querySql<{ name: string }>(
`SELECT name FROM sqlite_master WHERE type='table' AND name='voice_message_local'`,
);
if (rows.length === 0) return;
const now = Date.now();
await executeSql(
`INSERT OR IGNORE INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
SELECT conversation_id, voice_session_id, 0, file_uri, duration_ms, 'sent', 0, ?
FROM voice_message_local`,
[now],
);
await executeSql(`DROP TABLE IF EXISTS voice_message_local`);
}
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)`,
);
await migrateLegacyVoiceMessageLocal();
initialized = true;
}
@@ -38,14 +58,17 @@ function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
};
}
export interface VoicePlaybackRow {
voiceSessionId: string;
fileUri: string;
durationMs: number;
}
/**
* SQLite-backed outbox for voice segments.
* Stores metadata + queue state only audio files live on the local filesystem.
*
* State machine: pending sending sent | failed
* Single writer pattern enforced by serializing all writes through this module.
* outboxpendingsent
* `status=sent` voice_session_id REST
*/
export const segmentOutbox = {
export const voiceSegmentStore = {
async enqueue(
entry: Omit<
SegmentOutboxEntry,
@@ -68,6 +91,53 @@ export const segmentOutbox = {
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
@@ -110,24 +180,18 @@ export const segmentOutbox = {
await executeSql(sql, params);
},
async clearSent(conversationId?: string): Promise<void> {
await ensureTable();
const sql = conversationId
? `DELETE FROM segment_outbox WHERE status = 'sent' AND conversation_id = ?`
: `DELETE FROM segment_outbox WHERE status = 'sent'`;
const params = conversationId ? [conversationId] : [];
await executeSql(sql, params);
},
async clearAll(conversationId: string): Promise<void> {
async clearConversation(conversationId: string): Promise<void> {
await ensureTable();
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
conversationId,
]);
},
/** For testing — reset the initialization flag. */
/** @internal 测试用 */
_resetForTest(): void {
initialized = false;
},
} as const;
/** @deprecated 使用 voiceSegmentStore */
export const segmentOutbox = voiceSegmentStore;