2026-03-19 01:12:17 +08:00
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
2026-03-27 16:01:28 +08:00
|
|
|
|
import { File, Paths } from 'expo-file-system';
|
2026-03-19 01:12:17 +08:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
|
|
import type { WsConnectionState } from '@/core/ws/types';
|
|
|
|
|
|
|
|
|
|
|
|
import { conversationApi } from './api';
|
2026-03-20 16:36:42 +08:00
|
|
|
|
import { conversationMessagesRepository } from './conversation-messages-repository';
|
2026-03-19 01:12:17 +08:00
|
|
|
|
import { conversationKeys } from './query-keys';
|
|
|
|
|
|
import {
|
|
|
|
|
|
RealtimeSession,
|
|
|
|
|
|
type ErrorCallback,
|
|
|
|
|
|
type StreamingTextCallback,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
type TtsSegmentPayload,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
} from './realtime-session';
|
2026-03-20 16:36:42 +08:00
|
|
|
|
import {
|
|
|
|
|
|
type ConversationListItem,
|
|
|
|
|
|
type MessageItem,
|
|
|
|
|
|
type StreamingAgentMessage,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
} from './types';
|
2026-03-20 16:36:42 +08:00
|
|
|
|
import { voiceSegmentStore } from '@/features/voice/voice-segment-store';
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
/** Expo `File` 需要规范 `file://` URI;部分录音 API 会返回裸绝对路径。 */
|
|
|
|
|
|
function ensureFileUri(uri: string): string {
|
|
|
|
|
|
const u = uri.trim();
|
|
|
|
|
|
if (u.startsWith('file://')) return u;
|
|
|
|
|
|
if (u.startsWith('/')) return `file://${u}`;
|
|
|
|
|
|
return u;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function guessAudioExtension(uri: string): string {
|
|
|
|
|
|
const pathOnly = uri.split('?')[0] ?? uri;
|
|
|
|
|
|
const m = /\.[^/.]+$/u.exec(pathOnly);
|
|
|
|
|
|
return m ? m[0] : '.m4a';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 使用主包 `File`/`Paths`(见 Expo 文档:新 File 与旧 readAsStringAsync 互操作示例)。
|
|
|
|
|
|
* 先 copy 到 cache 下唯一文件名再 `base64()`,避免直接读源路径时偶发读到陈旧/错误内容。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function readRecordingPayload(uri: string): Promise<string> {
|
|
|
|
|
|
const resolved = ensureFileUri(uri);
|
|
|
|
|
|
const source = new File(resolved);
|
|
|
|
|
|
if (!source.exists) {
|
|
|
|
|
|
throw new Error('recording file missing');
|
|
|
|
|
|
}
|
|
|
|
|
|
const stagedName = `voice-upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}${guessAudioExtension(resolved)}`;
|
|
|
|
|
|
const staged = new File(Paths.cache, stagedName);
|
|
|
|
|
|
try {
|
|
|
|
|
|
source.copy(staged);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return await source.base64();
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await staged.base64();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
try {
|
|
|
|
|
|
staged.delete();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
// ─── Query hooks ───
|
|
|
|
|
|
|
|
|
|
|
|
// TODO: 连接不上后端时 isLoading 可能一直为 true,需加超时或展示错误态
|
|
|
|
|
|
export function useConversations() {
|
|
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: conversationKeys.lists(),
|
|
|
|
|
|
queryFn: () => conversationApi.list(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useConversationDetail(conversationId: string) {
|
|
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: conversationKeys.detail(conversationId),
|
|
|
|
|
|
queryFn: () => conversationApi.detail(conversationId),
|
|
|
|
|
|
enabled: !!conversationId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useMessages(conversationId: string) {
|
|
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: conversationKeys.messages(conversationId),
|
2026-03-20 16:36:42 +08:00
|
|
|
|
queryFn: () => conversationMessagesRepository.loadMessages(conversationId),
|
2026-03-19 01:12:17 +08:00
|
|
|
|
enabled: !!conversationId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Mutation hooks ───
|
|
|
|
|
|
|
|
|
|
|
|
export function useCreateConversation() {
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: () => conversationApi.create(),
|
|
|
|
|
|
onSuccess: (newConversation) => {
|
|
|
|
|
|
queryClient.setQueryData<ConversationListItem[]>(
|
|
|
|
|
|
conversationKeys.lists(),
|
|
|
|
|
|
(old) => {
|
|
|
|
|
|
const item: ConversationListItem = {
|
|
|
|
|
|
id: newConversation.id,
|
|
|
|
|
|
title: '岁月知己',
|
|
|
|
|
|
avatarUrl: null,
|
|
|
|
|
|
latestMessagePreview: '',
|
|
|
|
|
|
latestMessageTime: Date.now(),
|
|
|
|
|
|
unreadCount: 0,
|
|
|
|
|
|
isDefaultAssistant: true,
|
2026-03-26 16:28:33 +08:00
|
|
|
|
hasUserMessage: false,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
return [item, ...(old ?? [])];
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useDeleteConversation() {
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (conversationId: string) =>
|
|
|
|
|
|
conversationApi.delete(conversationId),
|
2026-03-20 16:36:42 +08:00
|
|
|
|
onSuccess: async (_, conversationId) => {
|
|
|
|
|
|
await voiceSegmentStore.clearConversation(conversationId);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
queryClient.setQueryData<ConversationListItem[]>(
|
|
|
|
|
|
conversationKeys.lists(),
|
|
|
|
|
|
(old) => old?.filter((item) => item.id !== conversationId),
|
|
|
|
|
|
);
|
|
|
|
|
|
queryClient.removeQueries({
|
|
|
|
|
|
queryKey: conversationKeys.detail(conversationId),
|
|
|
|
|
|
});
|
|
|
|
|
|
queryClient.removeQueries({
|
|
|
|
|
|
queryKey: conversationKeys.messages(conversationId),
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useEndConversation() {
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (conversationId: string) => conversationApi.end(conversationId),
|
|
|
|
|
|
onSuccess: (_, conversationId) => {
|
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
|
queryKey: conversationKeys.detail(conversationId),
|
|
|
|
|
|
});
|
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
|
queryKey: conversationKeys.lists(),
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Realtime session hook ───
|
|
|
|
|
|
|
|
|
|
|
|
interface UseRealtimeSessionOptions {
|
|
|
|
|
|
conversationId: string;
|
|
|
|
|
|
enabled?: boolean;
|
2026-03-20 16:36:42 +08:00
|
|
|
|
onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
/** 用户发出下一条文本/语音成功后调用,用于恢复接受 TTS 片段(打断后丢弃迟到片段) */
|
|
|
|
|
|
onTtsPlaybackResume?: () => void;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MIN_RECORDING_DURATION_SEC = 1;
|
|
|
|
|
|
|
|
|
|
|
|
function generateUUID(): string {
|
|
|
|
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
|
|
|
|
const r = (Math.random() * 16) | 0;
|
|
|
|
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
|
|
|
|
return v.toString(16);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface RealtimeSessionState {
|
|
|
|
|
|
connectionState: WsConnectionState;
|
|
|
|
|
|
streamingMessage: StreamingAgentMessage | null;
|
2026-03-26 16:28:33 +08:00
|
|
|
|
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
|
|
|
|
|
|
awaitingAssistantReply: boolean;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
error: string | null;
|
|
|
|
|
|
sendText: (text: string) => void;
|
|
|
|
|
|
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
|
|
|
|
|
sendEndConversation: () => void;
|
2026-03-26 15:51:24 +08:00
|
|
|
|
sendTtsCancel: () => void;
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useRealtimeSession({
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
enabled = true,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
onTtsSegment,
|
2026-03-26 15:51:24 +08:00
|
|
|
|
onTtsPlaybackResume,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
}: UseRealtimeSessionOptions): RealtimeSessionState {
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
const sessionRef = useRef<RealtimeSession | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const [connectionState, setConnectionState] =
|
|
|
|
|
|
useState<WsConnectionState>('disconnected');
|
|
|
|
|
|
const [streamingMessage, setStreamingMessage] =
|
|
|
|
|
|
useState<StreamingAgentMessage | null>(null);
|
2026-03-26 16:28:33 +08:00
|
|
|
|
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const handleStreamingText: StreamingTextCallback = useCallback(
|
|
|
|
|
|
(text, isComplete) => {
|
2026-03-26 16:28:33 +08:00
|
|
|
|
if (text.trim().length > 0) {
|
|
|
|
|
|
setAwaitingAssistantReply(false);
|
|
|
|
|
|
}
|
2026-03-19 01:12:17 +08:00
|
|
|
|
if (isComplete) {
|
|
|
|
|
|
setStreamingMessage(null);
|
2026-03-26 16:28:33 +08:00
|
|
|
|
setAwaitingAssistantReply(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStreamingMessage({ text, isComplete });
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleError: ErrorCallback = useCallback((message) => {
|
2026-03-26 16:28:33 +08:00
|
|
|
|
setAwaitingAssistantReply(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
setError(message);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!enabled || !conversationId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const session = new RealtimeSession({
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
queryClient,
|
|
|
|
|
|
onStreamingText: handleStreamingText,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
onTtsSegment,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
onError: handleError,
|
|
|
|
|
|
onStateChange: setConnectionState,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sessionRef.current = session;
|
|
|
|
|
|
session.connect();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
session.dispose();
|
|
|
|
|
|
sessionRef.current = null;
|
|
|
|
|
|
setConnectionState('disconnected');
|
|
|
|
|
|
setStreamingMessage(null);
|
2026-03-26 16:28:33 +08:00
|
|
|
|
setAwaitingAssistantReply(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
};
|
2026-03-19 10:24:48 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
queryClient,
|
|
|
|
|
|
handleStreamingText,
|
|
|
|
|
|
handleError,
|
2026-03-20 16:36:42 +08:00
|
|
|
|
onTtsSegment,
|
2026-03-19 10:24:48 +08:00
|
|
|
|
]);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
|
|
const sendText = useCallback(
|
|
|
|
|
|
(text: string) => {
|
|
|
|
|
|
if (!sessionRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const sent = sessionRef.current.sendText(text);
|
|
|
|
|
|
if (!sent) {
|
|
|
|
|
|
setError('消息发送失败,连接未就绪');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 16:28:33 +08:00
|
|
|
|
setAwaitingAssistantReply(true);
|
2026-03-26 15:51:24 +08:00
|
|
|
|
onTtsPlaybackResume?.();
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
const localId = `pending_${Date.now()}`;
|
|
|
|
|
|
|
|
|
|
|
|
queryClient.setQueryData<MessageItem[]>(
|
|
|
|
|
|
conversationKeys.messages(conversationId),
|
|
|
|
|
|
(old) => {
|
|
|
|
|
|
const msg: MessageItem = {
|
|
|
|
|
|
id: localId,
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
content: text,
|
|
|
|
|
|
senderType: 'user',
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
messageType: 'text',
|
|
|
|
|
|
};
|
|
|
|
|
|
return [...(old ?? []), msg];
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-26 16:28:33 +08:00
|
|
|
|
|
|
|
|
|
|
queryClient.setQueryData<ConversationListItem[]>(
|
|
|
|
|
|
conversationKeys.lists(),
|
|
|
|
|
|
(old) => {
|
|
|
|
|
|
if (!old) return old;
|
|
|
|
|
|
return old.map((item) =>
|
|
|
|
|
|
item.id === conversationId
|
|
|
|
|
|
? { ...item, hasUserMessage: true }
|
|
|
|
|
|
: item,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
},
|
2026-03-26 15:51:24 +08:00
|
|
|
|
[conversationId, queryClient, onTtsPlaybackResume],
|
2026-03-19 01:12:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const sendVoiceMessage = useCallback(
|
|
|
|
|
|
async (uri: string, durationMs: number): Promise<boolean> => {
|
|
|
|
|
|
const session = sessionRef.current;
|
|
|
|
|
|
if (!session) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const durationSec = Math.round(durationMs / 1000);
|
|
|
|
|
|
if (durationSec < MIN_RECORDING_DURATION_SEC) return false;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-27 16:01:28 +08:00
|
|
|
|
const base64 = await readRecordingPayload(uri);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
if (!base64) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const voiceSessionId = generateUUID();
|
|
|
|
|
|
const sent = session.sendAudioSegment(base64, 0, {
|
|
|
|
|
|
voiceSessionId,
|
|
|
|
|
|
clientSegmentId: `${voiceSessionId}-0`,
|
|
|
|
|
|
isLast: true,
|
|
|
|
|
|
duration: durationSec,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!sent) {
|
|
|
|
|
|
setError('语音发送失败,连接未就绪');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 16:28:33 +08:00
|
|
|
|
setAwaitingAssistantReply(true);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
const localId = `pending_voice_${Date.now()}`;
|
2026-03-20 16:36:42 +08:00
|
|
|
|
await voiceSegmentStore.recordSentSegment({
|
|
|
|
|
|
voiceSessionId,
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
fileUri: uri,
|
|
|
|
|
|
durationMs,
|
|
|
|
|
|
});
|
2026-03-19 01:12:17 +08:00
|
|
|
|
queryClient.setQueryData<MessageItem[]>(
|
|
|
|
|
|
conversationKeys.messages(conversationId),
|
|
|
|
|
|
(old) => {
|
|
|
|
|
|
const msg: MessageItem = {
|
|
|
|
|
|
id: localId,
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
senderType: 'user',
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
messageType: 'voice',
|
2026-03-20 16:36:42 +08:00
|
|
|
|
voiceSessionId,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
durationSeconds: durationSec,
|
|
|
|
|
|
audioUri: uri,
|
|
|
|
|
|
};
|
|
|
|
|
|
return [...(old ?? []), msg];
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-26 16:28:33 +08:00
|
|
|
|
|
|
|
|
|
|
queryClient.setQueryData<ConversationListItem[]>(
|
|
|
|
|
|
conversationKeys.lists(),
|
|
|
|
|
|
(old) => {
|
|
|
|
|
|
if (!old) return old;
|
|
|
|
|
|
return old.map((item) =>
|
|
|
|
|
|
item.id === conversationId
|
|
|
|
|
|
? { ...item, hasUserMessage: true }
|
|
|
|
|
|
: item,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-03-26 15:51:24 +08:00
|
|
|
|
onTtsPlaybackResume?.();
|
2026-03-19 01:12:17 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError('语音文件读取失败');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-26 15:51:24 +08:00
|
|
|
|
[conversationId, queryClient, onTtsPlaybackResume],
|
2026-03-19 01:12:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const sendEndConversation = useCallback(() => {
|
|
|
|
|
|
sessionRef.current?.sendEndConversation();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-26 15:51:24 +08:00
|
|
|
|
const sendTtsCancel = useCallback(() => {
|
|
|
|
|
|
sessionRef.current?.sendTtsCancel();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-19 01:12:17 +08:00
|
|
|
|
return {
|
|
|
|
|
|
connectionState,
|
|
|
|
|
|
streamingMessage,
|
2026-03-26 16:28:33 +08:00
|
|
|
|
awaitingAssistantReply,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
error,
|
|
|
|
|
|
sendText,
|
|
|
|
|
|
sendVoiceMessage,
|
|
|
|
|
|
sendEndConversation,
|
2026-03-26 15:51:24 +08:00
|
|
|
|
sendTtsCancel,
|
2026-03-19 01:12:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|