diff --git a/app/ai-coach-chat.tsx b/app/ai-coach-chat.tsx index eb404cf..fd7320d 100644 --- a/app/ai-coach-chat.tsx +++ b/app/ai-coach-chat.tsx @@ -4,10 +4,13 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + Alert, FlatList, Image, KeyboardAvoidingView, + Modal, Platform, + ScrollView, StyleSheet, Text, TextInput, @@ -21,9 +24,11 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; import { api, getAuthToken, postTextStream } from '@/services/api'; import type { CheckinRecord } from '@/store/checkinSlice'; +import dayjs from 'dayjs'; type Role = 'user' | 'assistant'; @@ -52,6 +57,11 @@ export default function AICoachChatScreen() { role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, }]); + const [historyVisible, setHistoryVisible] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyPage, setHistoryPage] = useState(1); + const [historyTotal, setHistoryTotal] = useState(0); + const [historyItems, setHistoryItems] = useState([]); const listRef = useRef>(null); const [isAtBottom, setIsAtBottom] = useState(true); const didInitialScrollRef = useRef(false); @@ -145,6 +155,70 @@ export default function AICoachChatScreen() { .map((m) => ({ role: m.role, content: m.content })); } + async function openHistory() { + if (isStreaming) { + try { streamAbortRef.current?.abort(); } catch { } + } + setHistoryVisible(true); + await refreshHistory(1); + } + + async function refreshHistory(page = 1) { + try { + setHistoryLoading(true); + const resp = await listConversations(page, 20); + setHistoryPage(resp.page); + setHistoryTotal(resp.total); + setHistoryItems(resp.items || []); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '获取会话列表失败'); + } finally { + setHistoryLoading(false); + } + } + + async function handleSelectConversation(id: string) { + try { + if (isStreaming) { + try { streamAbortRef.current?.abort(); } catch { } + } + const detail = await getConversationDetail(id); + if (!detail || !(detail as any).messages) { + Alert.alert('提示', '会话不存在或已删除'); + return; + } + const mapped: ChatMessage[] = (detail.messages || []) + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' })); + setConversationId(detail.conversationId); + setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + setHistoryVisible(false); + setTimeout(scrollToEnd, 0); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '加载会话失败'); + } + } + + function confirmDeleteConversation(id: string) { + Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', style: 'destructive', onPress: async () => { + try { + await deleteConversation(id); + if (conversationId === id) { + setConversationId(undefined); + setMessages([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); + } + await refreshHistory(historyPage); + } catch (e) { + Alert.alert('错误', (e as any)?.message || '删除失败'); + } + } + } + ]); + } + async function sendStream(text: string) { const tokenExists = !!getAuthToken(); try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { } @@ -338,6 +412,11 @@ export default function AICoachChatScreen() { onBack={() => router.back()} tone="light" transparent + right={( + + + + )} /> )} + + setHistoryVisible(false)}> + setHistoryVisible(false)}> + + + 历史会话 + refreshHistory(historyPage)} style={styles.modalRefreshBtn}> + + + + {historyLoading ? ( + + + 加载中... + + ) : ( + + {historyItems.length === 0 ? ( + 暂无会话 + ) : ( + historyItems.map((it) => ( + + handleSelectConversation(it.conversationId)} + > + {it.title || '未命名会话'} + + {dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')} + + + confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}> + + + + )) + )} + + )} + + setHistoryVisible(false)} style={styles.modalCloseBtn}> + 关闭 + + + + + ); } @@ -554,6 +680,74 @@ const styles = StyleSheet.create({ shadowRadius: 4, elevation: 2, }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + padding: 16, + justifyContent: 'flex-end', + }, + modalSheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 12, + paddingTop: 10, + paddingBottom: 12, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 4, + paddingBottom: 8, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + color: '#192126', + }, + modalRefreshBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.06)' + }, + historyRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: 10, + }, + historyTitle: { + fontSize: 15, + color: '#192126', + fontWeight: '600', + }, + historyMeta: { + marginTop: 2, + fontSize: 12, + color: '#687076', + }, + historyDeleteBtn: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,68,68,0.08)' + }, + modalFooter: { + paddingTop: 8, + alignItems: 'flex-end', + }, + modalCloseBtn: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: 'rgba(0,0,0,0.06)' + }, }); diff --git a/hooks/useCosUpload.ts b/hooks/useCosUpload.ts index 3b83eb6..53d9f00 100644 --- a/hooks/useCosUpload.ts +++ b/hooks/useCosUpload.ts @@ -18,7 +18,7 @@ export function useCosUpload(defaultOptions?: UseCosUploadOptions) { }, []); const upload = useCallback( - async (file: { uri?: string; name?: string; type?: string; buffer?: any; blob?: Blob } | Blob | any, options?: UseCosUploadOptions) => { + async (file: { uri?: string; name?: string; type?: string; buffer?: any; blob?: Blob } | Blob | string | any, options?: UseCosUploadOptions) => { const finalOptions = { ...(defaultOptions || {}), ...(options || {}) }; const extGuess = (() => { const name = (file && (file.name || (file as any).filename)) || ''; @@ -31,11 +31,29 @@ export function useCosUpload(defaultOptions?: UseCosUploadOptions) { setProgress(0); setUploading(true); try { - let body = (file as any)?.blob || (file as any)?.buffer || file; - // Expo ImagePicker 返回 { uri } 时,转换为 Blob - if (!body && (file as any)?.uri) { + let body: any = null; + // 1) 直接可用类型:Blob 或 string + if (typeof file === 'string') { + body = file; + } else if (typeof Blob !== 'undefined' && file instanceof Blob) { + body = file; + } else if ((file as any)?.blob && (typeof Blob === 'undefined' || (file as any).blob instanceof Blob || (file as any).blob?._data)) { + // 2) 已提供 blob 字段 + body = (file as any).blob; + } else if ((file as any)?.buffer) { + // 3) ArrayBuffer/TypedArray -> Blob + const buffer = (file as any).buffer; + body = new Blob([buffer], { type: (file as any)?.type || finalOptions.contentType || 'application/octet-stream' }); + } else if ((file as any)?.uri) { + // 4) Expo ImagePicker/文件:必须先转 Blob const resp = await fetch((file as any).uri); body = await resp.blob(); + } else { + // 兜底:尝试直接作为字符串,否则抛错 + if (file && (typeof file === 'object')) { + throw new Error('无效的上传体:请提供 Blob/String,或包含 uri 的对象'); + } + body = file; } const res = await uploadWithRetry({ key, diff --git a/services/aiCoach.ts b/services/aiCoach.ts new file mode 100644 index 0000000..81309f3 --- /dev/null +++ b/services/aiCoach.ts @@ -0,0 +1,38 @@ +import { api } from '@/services/api'; + +export type AiConversationListItem = { + conversationId: string; + title?: string | null; + lastMessageAt?: string | null; + createdAt?: string | null; +}; + +export type AiConversationListResponse = { + page: number; + pageSize: number; + total: number; + items: AiConversationListItem[]; +}; + +export type AiConversationDetail = { + conversationId: string; + title?: string | null; + lastMessageAt?: string | null; + createdAt?: string | null; + messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string; createdAt?: string | null }>; +}; + +export async function listConversations(page = 1, pageSize = 20): Promise { + const qs = `?page=${encodeURIComponent(page)}&pageSize=${encodeURIComponent(pageSize)}`; + return api.get(`/api/ai-coach/conversations${qs}`); +} + +export async function getConversationDetail(conversationId: string): Promise { + return api.get(`/api/ai-coach/conversations/${encodeURIComponent(conversationId)}`); +} + +export async function deleteConversation(conversationId: string): Promise<{ success: boolean }> { + return api.delete<{ success: boolean }>(`/api/ai-coach/conversations/${encodeURIComponent(conversationId)}`); +} + +