feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录 - 实现轻量防抖机制,确保会话变动时及时保存缓存 - 在打卡功能中集成按月加载打卡记录,提升用户体验 - 更新 Redux 状态管理,支持打卡记录的按月加载和缓存 - 新增打卡日历页面,允许用户查看每日打卡记录 - 优化样式以适应新功能的展示和交互
This commit is contained in:
@@ -21,6 +21,8 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||
import { api, getAuthToken, postTextStream } from '@/services/api';
|
||||
import type { CheckinRecord } from '@/store/checkinSlice';
|
||||
|
||||
type Role = 'user' | 'assistant';
|
||||
@@ -43,6 +45,8 @@ export default function AICoachChatScreen() {
|
||||
const coachName = (params?.name || 'Sarah').toString();
|
||||
const [input, setInput] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([{
|
||||
id: 'm_welcome',
|
||||
role: 'assistant',
|
||||
@@ -82,6 +86,31 @@ export default function AICoachChatScreen() {
|
||||
// 初次进入或恢复时,保持最新消息可见
|
||||
scrollToEnd();
|
||||
}, [scrollToEnd]);
|
||||
// 启动页面时尝试恢复当次应用会话缓存
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const cached = await loadAiCoachSessionCache();
|
||||
if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) {
|
||||
setConversationId(cached.conversationId);
|
||||
setMessages(cached.messages as any);
|
||||
setTimeout(scrollToEnd, 0);
|
||||
}
|
||||
} catch { }
|
||||
})();
|
||||
}, [scrollToEnd]);
|
||||
|
||||
// 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空)
|
||||
const saveCacheTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current);
|
||||
saveCacheTimerRef.current = setTimeout(() => {
|
||||
saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { });
|
||||
}, 150);
|
||||
return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); };
|
||||
// 仅在 messages 或 conversationId 变化时触发
|
||||
}, [messages, conversationId]);
|
||||
|
||||
|
||||
// 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发
|
||||
|
||||
@@ -93,39 +122,128 @@ export default function AICoachChatScreen() {
|
||||
}
|
||||
}, [composerHeight, isAtBottom, scrollToEnd]);
|
||||
|
||||
async function fakeStreamResponse(prompt: string): Promise<string> {
|
||||
// 占位实现:模拟AI逐字输出(可替换为真实后端流式接口)
|
||||
const canned =
|
||||
prompt.includes('训练计划') || prompt.includes('制定')
|
||||
? '好的,我将基于你的目标与时间安排制定一周普拉提计划:\n\n- 周一:核心激活与呼吸(20-25分钟)\n- 周三:下肢稳定与髋部灵活(25-30分钟)\n- 周五:全身整合与平衡(30分钟)\n\n每次训练前后各进行5分钟呼吸与拉伸。若有不适请降低强度或暂停。'
|
||||
: '已收到,我会根据你的问题给出建议:保持规律练习与充分恢复,注意呼吸控制与动作节奏。若感到疼痛请及时调整或咨询专业教练。';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return canned;
|
||||
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try { streamAbortRef.current?.abort(); } catch { }
|
||||
};
|
||||
}, []);
|
||||
|
||||
function ensureConversationId(): string {
|
||||
if (conversationId && conversationId.trim()) return conversationId;
|
||||
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
setConversationId(cid);
|
||||
try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { }
|
||||
return cid;
|
||||
}
|
||||
|
||||
function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
|
||||
// 仅映射 user/assistant 消息;系统提示由后端自动注入
|
||||
return history
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
}
|
||||
|
||||
async function sendStream(text: string) {
|
||||
const tokenExists = !!getAuthToken();
|
||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { }
|
||||
|
||||
// 终止上一次未完成的流
|
||||
if (streamAbortRef.current) {
|
||||
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
|
||||
try { streamAbortRef.current.abort(); } catch { }
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
|
||||
// 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文
|
||||
const historyForServer = convertToServerMessages(messages);
|
||||
const cid = ensureConversationId();
|
||||
const body = {
|
||||
conversationId: cid,
|
||||
messages: [...historyForServer, { role: 'user' as const, content: text }],
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// 在 UI 中先放置占位回答,随后持续增量更新
|
||||
const assistantId = `a_${Date.now()}`;
|
||||
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text };
|
||||
shouldAutoScrollRef.current = isAtBottom;
|
||||
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
|
||||
|
||||
setIsSending(true);
|
||||
setIsStreaming(true);
|
||||
|
||||
let receivedAnyChunk = false;
|
||||
|
||||
const updateAssistantContent = (delta: string) => {
|
||||
setMessages((prev) => {
|
||||
const next = prev.map((msg) => {
|
||||
if (msg.id === assistantId) {
|
||||
return { ...msg, content: msg.content + delta };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onChunk = (chunk: string) => {
|
||||
receivedAnyChunk = true;
|
||||
const atBottomNow = isAtBottom;
|
||||
updateAssistantContent(chunk);
|
||||
if (atBottomNow) {
|
||||
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
|
||||
shouldAutoScrollRef.current = true;
|
||||
setTimeout(scrollToEnd, 0);
|
||||
}
|
||||
try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { }
|
||||
};
|
||||
|
||||
const onEnd = (cidFromHeader?: string) => {
|
||||
setIsSending(false);
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
if (cidFromHeader && !conversationId) setConversationId(cidFromHeader);
|
||||
try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
|
||||
};
|
||||
|
||||
const onError = async (err: any) => {
|
||||
try { console.warn('[AI_CHAT][api] error', err); } catch { }
|
||||
setIsSending(false);
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
// 流式失败时的降级:尝试一次性非流式
|
||||
try {
|
||||
const bodyNoStream = { ...body, stream: false };
|
||||
try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { }
|
||||
const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
|
||||
const textCombined = (resp as any)?.text ?? '';
|
||||
if ((resp as any)?.conversationId && !conversationId) {
|
||||
setConversationId((resp as any).conversationId);
|
||||
}
|
||||
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
|
||||
} catch (e2: any) {
|
||||
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg));
|
||||
try { console.warn('[AI_CHAT][fallback] non-stream error', e2); } catch { }
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 });
|
||||
streamAbortRef.current = controller;
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
if (!text.trim() || isSending) return;
|
||||
const userMsg: ChatMessage = { id: `u_${Date.now()}`, role: 'user', content: text.trim() };
|
||||
// 标记:这次是新增消息,等内容真正渲染并触发 onContentSizeChange 时再滚动
|
||||
shouldAutoScrollRef.current = isAtBottom;
|
||||
setMessages((m) => [...m, userMsg]);
|
||||
const trimmed = text.trim();
|
||||
setInput('');
|
||||
setIsSending(true);
|
||||
// 立即滚动改为延后到 onContentSizeChange,避免突兀
|
||||
|
||||
try {
|
||||
const replyText = await fakeStreamResponse(text.trim());
|
||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: replyText };
|
||||
// 同理:AI 消息到达时,与内容变化同步滚动
|
||||
shouldAutoScrollRef.current = isAtBottom;
|
||||
setMessages((m) => [...m, aiMsg]);
|
||||
} catch (e) {
|
||||
const aiMsg: ChatMessage = { id: `a_${Date.now()}`, role: 'assistant', content: '抱歉,请求失败,请稍后再试。' };
|
||||
shouldAutoScrollRef.current = isAtBottom;
|
||||
setMessages((m) => [...m, aiMsg]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
await sendStream(trimmed);
|
||||
}
|
||||
|
||||
function handleQuickPlan() {
|
||||
|
||||
Reference in New Issue
Block a user