feat: 优化 AI 教练聊天和打卡功能

- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-14 09:57:13 +08:00
parent 7ad26590e5
commit e3e2f1b8c6
18 changed files with 918 additions and 117 deletions

View File

@@ -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() {