OCR 模型按需下载(P8 瘦身): - 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB - 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝 - OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户 - settingsStore 持久化 ocrModelVersion / ocrModelDir - 自动化页集成模型状态检查与一键下载 UI OCR 三层独立控制: - OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关 - 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision) - OCR 处理增加耗时与字符数日志 日志系统升级: - logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log) - 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx - 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理 - _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号) 原生浮层 UI 主题同步(P6): - 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生 - 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取 - FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal - FloatingHelper / FloatingTip 同步适配 - _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送 UI 与组件增强: - FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange - 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗 - 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择 - AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型 - 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗) 去重优化: - 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复 - DedupResult 新增 matchedItem 返回匹配对比项 文档重构: - README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表) - 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图) - 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md - UI 重设计文档(design spec + p1-p8)移入 docs/design/ 其他: - i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键 - ppocr Config Plugin 修复 import 注入去重;size-optimization 增强 - 新增测试:logger.test.ts、floating-ui-config.test.ts
204 lines
8.7 KiB
TypeScript
204 lines
8.7 KiB
TypeScript
/**
|
||
* 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<typeof useSettingsStore.getState>): 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<UiMessage[]>([
|
||
{ role: 'assistant', content: t('ai.welcome') },
|
||
]);
|
||
const conversationRef = useRef<ChatConversation>(createConversation());
|
||
const listRef = useRef<FlatList<UiMessage>>(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 (
|
||
<View style={[styles.msgRow, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}>
|
||
<View style={[
|
||
styles.bubble,
|
||
{
|
||
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
|
||
borderColor: theme.colors.border,
|
||
},
|
||
]}>
|
||
<Text style={{ color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 15, lineHeight: 20 }}>
|
||
{item.content}
|
||
</Text>
|
||
{/* 账单卡片 */}
|
||
{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 (
|
||
<Pressable
|
||
key={i}
|
||
onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
|
||
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
|
||
>
|
||
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
|
||
{dirSign} {card.amount} {card.currency}
|
||
</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||
{card.counterparty} · {card.narration}
|
||
</Text>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
|
||
{t('ai.tapToRecord')}
|
||
</Text>
|
||
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<ScreenHeader title={t('ai.chatTitle')} />
|
||
|
||
<FlatList
|
||
ref={listRef}
|
||
data={messages}
|
||
keyExtractor={(item, index) => `${index}`}
|
||
renderItem={renderMessage}
|
||
contentContainerStyle={{ padding: 16, gap: 8 }}
|
||
onContentSizeChange={() => listRef.current?.scrollToEnd()}
|
||
/>
|
||
|
||
{loading && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', paddingBottom: 4 }]}>
|
||
{t('ai.thinking')}
|
||
</Text>
|
||
)}
|
||
|
||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
|
||
<TextInput
|
||
value={input}
|
||
onChangeText={setInput}
|
||
placeholder={t('ai.inputPlaceholder')}
|
||
placeholderTextColor={theme.colors.fgSecondary}
|
||
style={[styles.input, { color: theme.colors.fgPrimary }]}
|
||
multiline
|
||
/>
|
||
<Pressable
|
||
onPress={sendMessage}
|
||
disabled={!input.trim() || loading}
|
||
style={({ pressed }) => [
|
||
styles.sendBtn,
|
||
{ backgroundColor: theme.colors.accent, opacity: (!input.trim() || loading) ? 0.4 : pressed ? 0.7 : 1 },
|
||
]}
|
||
>
|
||
<Ionicons name="send" size={18} color={theme.colors.fgInverse} />
|
||
</Pressable>
|
||
</View>
|
||
</KeyboardAvoidingView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
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' },
|
||
});
|