Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
279
app-expo/src/features/conversation/hooks.ts
Normal file
279
app-expo/src/features/conversation/hooks.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { File } from 'expo-file-system';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WsConnectionState } from '@/core/ws/types';
|
||||
|
||||
import { conversationApi } from './api';
|
||||
import { conversationKeys } from './query-keys';
|
||||
import {
|
||||
RealtimeSession,
|
||||
type ErrorCallback,
|
||||
type StreamingTextCallback,
|
||||
} from './realtime-session';
|
||||
import type {
|
||||
ConversationListItem,
|
||||
MessageItem,
|
||||
StreamingAgentMessage,
|
||||
} from './types';
|
||||
|
||||
// ─── 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),
|
||||
queryFn: () => conversationApi.messages(conversationId),
|
||||
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,
|
||||
};
|
||||
return [item, ...(old ?? [])];
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (conversationId: string) =>
|
||||
conversationApi.delete(conversationId),
|
||||
onSuccess: (_, conversationId) => {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
error: string | null;
|
||||
sendText: (text: string) => void;
|
||||
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
||||
sendEndConversation: () => void;
|
||||
}
|
||||
|
||||
export function useRealtimeSession({
|
||||
conversationId,
|
||||
enabled = true,
|
||||
}: UseRealtimeSessionOptions): RealtimeSessionState {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionRef = useRef<RealtimeSession | null>(null);
|
||||
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<WsConnectionState>('disconnected');
|
||||
const [streamingMessage, setStreamingMessage] =
|
||||
useState<StreamingAgentMessage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleStreamingText: StreamingTextCallback = useCallback(
|
||||
(text, isComplete) => {
|
||||
if (isComplete) {
|
||||
setStreamingMessage(null);
|
||||
return;
|
||||
}
|
||||
setStreamingMessage({ text, isComplete });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleError: ErrorCallback = useCallback((message) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !conversationId) return;
|
||||
|
||||
const session = new RealtimeSession({
|
||||
conversationId,
|
||||
queryClient,
|
||||
onStreamingText: handleStreamingText,
|
||||
onError: handleError,
|
||||
onStateChange: setConnectionState,
|
||||
});
|
||||
|
||||
sessionRef.current = session;
|
||||
session.connect();
|
||||
|
||||
return () => {
|
||||
session.dispose();
|
||||
sessionRef.current = null;
|
||||
setConnectionState('disconnected');
|
||||
setStreamingMessage(null);
|
||||
};
|
||||
}, [conversationId, enabled, queryClient, handleStreamingText, handleError]);
|
||||
|
||||
const sendText = useCallback(
|
||||
(text: string) => {
|
||||
if (!sessionRef.current) return;
|
||||
|
||||
const sent = sessionRef.current.sendText(text);
|
||||
if (!sent) {
|
||||
setError('消息发送失败,连接未就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
},
|
||||
);
|
||||
},
|
||||
[conversationId, queryClient],
|
||||
);
|
||||
|
||||
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 {
|
||||
const file = new File(uri);
|
||||
const base64 = await file.base64();
|
||||
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;
|
||||
}
|
||||
|
||||
const localId = `pending_voice_${Date.now()}`;
|
||||
queryClient.setQueryData<MessageItem[]>(
|
||||
conversationKeys.messages(conversationId),
|
||||
(old) => {
|
||||
const msg: MessageItem = {
|
||||
id: localId,
|
||||
conversationId,
|
||||
content: '',
|
||||
senderType: 'user',
|
||||
timestamp: Date.now(),
|
||||
messageType: 'voice',
|
||||
durationSeconds: durationSec,
|
||||
audioUri: uri,
|
||||
};
|
||||
return [...(old ?? []), msg];
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
setError('语音文件读取失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[conversationId, queryClient],
|
||||
);
|
||||
|
||||
const sendEndConversation = useCallback(() => {
|
||||
sessionRef.current?.sendEndConversation();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
streamingMessage,
|
||||
error,
|
||||
sendText,
|
||||
sendVoiceMessage,
|
||||
sendEndConversation,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user