/** * AI 聊天助手(plan.md「8.1 AI 聊天助手」)。 * * 功能: * - 自然语言记账("昨天星巴克花了35" → 生成账单卡片) * - 自由聊天(AI 回复) * - 账单卡片点击跳转 new.tsx 预填 * * 使用 chatAssistant.processChatMessage + BaseOpenAIProvider(OpenAI 兼容协议)。 * AI 未配置时降级为提示用户去设置。 */ import React, { useState, useCallback, useRef } from 'react'; import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useTheme } from '../../theme'; import { useT } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { useNumpadUiStore } from '../../store/numpadUiStore'; import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai'; import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant'; import { ScreenHeader } from '../../components/ScreenHeader'; interface UiMessage { role: 'user' | 'assistant'; content: string; billCards?: ChatResponse['billCards']; } /** 构造 AiProvider(从 settingsStore)。 */ function buildProvider(get: ReturnType): AiProvider | null { if (!get.aiEnabled || !get.aiApiKey) return null; const config: AiProviderConfig = { id: get.aiProviderId, name: get.aiProviderId, apiKey: get.aiApiKey, baseUrl: get.aiBaseUrl || 'https://api.openai.com/v1', model: get.aiModel || 'gpt-4o-mini', }; // BaseOpenAIProvider 是 abstract 但 chat 方法已实现,创建匿名子类 return new (class extends BaseOpenAIProvider {})(config); } export default function AIChatScreen() { const { theme } = useTheme(); const t = useT(); const router = useRouter(); const aiEnabled = useSettingsStore(s => s.aiEnabled); const aiApiKey = useSettingsStore(s => s.aiApiKey); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([ { role: 'assistant', content: t('ai.welcome') }, ]); const conversationRef = useRef(createConversation()); const listRef = useRef>(null); const sendMessage = useCallback(async () => { const text = input.trim(); if (!text || loading) return; // 添加用户消息 const userMsg: UiMessage = { role: 'user', content: text }; setMessages(prev => [...prev, userMsg]); setInput(''); setLoading(true); try { const provider = buildProvider(useSettingsStore.getState()); if (!provider) { setMessages(prev => [...prev, { role: 'assistant', content: t('ai.notConfigured') }]); return; } const response = await processChatMessage(text, provider, conversationRef.current); conversationRef.current = appendMessage(conversationRef.current, { role: 'user', content: text }); if (response.type === 'bill_card' && response.billCards && response.billCards.length > 0) { const assistantMsg: UiMessage = { role: 'assistant', content: t('ai.billDetected'), billCards: response.billCards, }; setMessages(prev => [...prev, assistantMsg]); conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text || '' }); } else if (response.type === 'text' && response.text) { setMessages(prev => [...prev, { role: 'assistant', content: response.text! }]); conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text }); } else if (response.type === 'error') { setMessages(prev => [...prev, { role: 'assistant', content: response.error || t('ai.error') }]); } else { setMessages(prev => [...prev, { role: 'assistant', content: response.text || t('ai.error') }]); } } catch (e) { setMessages(prev => [...prev, { role: 'assistant', content: `${t('ai.error')}: ${e instanceof Error ? e.message : String(e)}` }]); } finally { setLoading(false); setTimeout(() => listRef.current?.scrollToEnd(), 100); } }, [input, loading, t]); const renderMessage = ({ item }: { item: UiMessage }) => { const isUser = item.role === 'user'; return ( {item.content} {/* 账单卡片 */} {item.billCards && item.billCards.map((card, i) => { // 方向符号 + 金额上色,与全局方向色约定一致(TransactionCard:income 绿 / expense 红 / transfer 蓝) const dirColor = card.type === 'income' ? theme.colors.financial.income : card.type === 'transfer' ? theme.colors.financial.transfer : theme.colors.financial.expense; const dirSign = card.type === 'income' ? '+' : card.type === 'transfer' ? '⇄' : '-'; return ( useNumpadUiStore.getState().open({ autoOcr: true })} style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]} > {dirSign} {card.amount} {card.currency} {card.counterparty} · {card.narration} {t('ai.tapToRecord')} ); })} ); }; return ( `${index}`} renderItem={renderMessage} contentContainerStyle={{ padding: 16, gap: 8 }} onContentSizeChange={() => listRef.current?.scrollToEnd()} /> {loading && ( {t('ai.thinking')} )} [ styles.sendBtn, { backgroundColor: theme.colors.accent, opacity: (!input.trim() || loading) ? 0.4 : pressed ? 0.7 : 1 }, ]} > ); } const styles = StyleSheet.create({ page: { flex: 1 }, msgRow: { flexDirection: 'row', maxWidth: '100%' }, bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 }, billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 }, input: { flex: 1, maxHeight: 100, fontSize: 15, paddingVertical: 8 }, sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, });