修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface SegmenterConfig {
|
||||
fixedDurationMs: number;
|
||||
}
|
||||
|
||||
// ─── Segment outbox ───
|
||||
// ─── 本地语音分段(outbox + 已发送可回放元数据,见 voice-segment-store)───
|
||||
|
||||
export type SegmentOutboxStatus = 'pending' | 'sending' | 'sent' | 'failed';
|
||||
|
||||
|
||||
@@ -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.
|
||||
* 本地语音分段:outbox(pending→sent)与可回放元数据共用同一张表。
|
||||
* 音频文件在文件系统;`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;
|
||||
Reference in New Issue
Block a user