# P8:自动记账可配置化 + 模型按需下载 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git 操作**。不创建额外任务清单。 **Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向;OCR 模型从 APK assets 改为应用内下载(filesystem 路径加载 ONNX)。 **背景决策**: - 三层开关默认值:L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启) - 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示 - `settings/ai.tsx` 删除,原入口重定向到 automation 页 - 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关 - 模型从 assets 复制到 filesystem:预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectory,OcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。 --- ### Task 1: settingsStore 新增字段 + 迁移 **Files:** `src/store/settingsStore.ts` - [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter** 在 `SettingsState` 接口加 5 个新字段: ```typescript /** 自动记账 L1:正则规则匹配 */ layer1RuleEnabled: boolean; /** 自动记账 L2:本地 OCR 识别 */ layer2OcrEnabled: boolean; /** 自动记账 L3:云端 AI Vision */ layer3AiEnabled: boolean; /** OCR 模型版本(已下载),空字符串表示未安装 */ ocrModelVersion: string; /** OCR 模型下载路径 */ ocrModelDir: string; ``` DEFAULTS: - `layer1RuleEnabled: true` - `layer2OcrEnabled: true` - `layer3AiEnabled: false` - `ocrModelVersion: ''` - `ocrModelDir: ''` `PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。 **注意**:`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。 - [ ] **Step 2: 验证** `npm run typecheck` → 0 错误;`npm test` → 全绿 --- ### Task 2: OcrProcessor 逐级回退逻辑 **Files:** `src/domain/ocrProcessor.ts`、`src/services/ocrBridge.ts`、`src/services/automationPipeline.ts` - [ ] **Step 1: OcrProcessorConfig 扩展** `OcrProcessorConfig` 接口加: ```typescript layer1Enabled: boolean; // 默认 true layer2Enabled: boolean; // 默认 true layer3Enabled: boolean; // 默认 false ``` - [ ] **Step 2: doProcess() 逐级跳过** 在 `OcrProcessor.doProcess()` 中修改流程(约 :103-162): ``` // 1. 横屏 / 图片去重 守卫不变 // 2. L1+L2 需要 OCR 文本 if (config.layer1Enabled || config.layer2Enabled) { if (!ocrEngine) 返回 none(引擎不可用) ocrText = await ocrEngine.recognizeText(imageBase64) if (!ocrText) → 如果 L3 启用则 goto L3,否则返回 none } else { ocrText = '' } // 3. Layer 1(仅当启用) if (config.layer1Enabled && ocrText && !isDetailPage) { result = matchOcrRule(ocrText, packageName) if (result) return { layer: 'layer1-rule', ... } } // 4. Layer 2(仅当启用) if (config.layer2Enabled && ocrText) { result = parseOcrBill(ocrText, packageName) if (result) return { layer: 'layer2-ocr', ... } } // 5. Layer 3(仅当启用) if (config.layer3Enabled) { return tryLayer3(imageBase64, ocrText) } // 6. 全部未命中或全部关闭 return { event: null, layer: 'none', skipped: 'non-bill' } ``` - [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段** `getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`。 - [ ] **Step 4: 验证** `npm run typecheck` → 0 错误;`npm test` → 全绿 --- ### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径 **Files:** `plugins/ppocr/android/OcrModule.kt`、`plugins/ppocr/app.plugin.js`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法) - [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法** ```kotlin @ReactMethod fun copyOcrModelsFromAssets(promise: Promise) { try { val assetManager = reactContext.assets val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6") if (!destDir.exists()) destDir.mkdirs() val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt") for (filename in files) { val destFile = java.io.File(destDir, filename) if (destFile.exists()) continue // skip existing val input = assetManager.open(filename) val output = java.io.FileOutputStream(destFile) val buffer = ByteArray(8192) var bytesRead: Int while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) } input.close() output.close() } promise.resolve(destDir.absolutePath) } catch (e: Exception) { promise.reject("COPY_MODEL_FAIL", e.message) } } ``` - [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载** 读 `OcrModule.kt` 中 `createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`。 **注意**:ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径(String),不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。 - [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务** `NativeAccessibilityBridge` 加 `copyOcrModelsFromAssets(): Promise` → 返回模型目录绝对路径。 新建 `src/services/modelDownloader.ts`: ```typescript export async function ensureOcrModels(): Promise { // 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回 // 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6 // 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion } ``` `ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。 - [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化** `getNativeOcrBridge()`或 `getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize)。 - [ ] **Step 5: 验证** `cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL --- ### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合 **Files:** `src/app/automation/index.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts` - [ ] **Step 1: i18n 新键(zh/en 对等)** 在 `automation` section 加: ``` autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers layer1Rule 规则匹配 / Rule Matching layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition layer2Ocr OCR 识别 / OCR Recognition layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model layer3Ai AI 视觉识别 / AI Vision layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM layer3AiDisabled 请先配置 AI Key / Configure AI Key first ocrModelTitle OCR 模型 / OCR Model ocrModelNotInstalled 未安装 / Not Installed ocrModelInstalled 已安装 ({version}) / Installed ({version}) ocrModelInstall 安装模型 / Install Model ocrModelInstalling 安装中... / Installing... ocrModelCopying 正在复制模型文件... / Copying model files... dedupSwitch 多通道联合去重 / Cross-channel Dedup transferSwitch 转账智能识别 / Transfer Recognition ``` - [ ] **Step 2: automation 页 UI 重组织** 在现有截图监控按钮 Card **之后**加以下新 Card 区块: **Card A:「自动记账层级」** - 三行 Switch:L1 规则匹配(always enabled,不依赖外部条件) - L2 OCR 识别(Switch;开启时检查模型→无模型则弹下载提示 Dialog) - L3 AI 视觉(Switch;如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入) **Card B:「OCR 模型」** - 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」) - 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成) **Card C:「管道设置」**(复用现有 Card B 和 C 部分) - `dedupEnabled` Switch - `transferRecognitionEnabled` Switch - [ ] **Step 3: 操作逻辑** ```typescript // 模型安装 const handleInstallModel = async () => { setModelStatus('installing'); try { const bridge = getAccessibilityBridge(); if (!bridge) throw new Error('Bridge unavailable'); const modelDir = await bridge.copyOcrModelsFromAssets(); setOcrModelVersion('v6-assets'); setOcrModelDir(modelDir); setModelStatus('done'); } catch (e) { setModelStatus('error'); Alert.alert('安装失败', String(e)); } }; // L2 开关变化时检查模型 const handleL2Toggle = (val: boolean) => { setLayer2OcrEnabled(val); if (val && !ocrModelVersion) { Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [ { text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) }, { text: t('automation.ocrModelInstall'), onPress: handleInstallModel }, ]); } }; // L3 开关变化时检查 AI Key const handleL3Toggle = (val: boolean) => { if (val && (!aiEnabled || !aiApiKey)) { Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled')); return; } setLayer3AiEnabled(val); }; ``` - [ ] **Step 4: AI 配置内联** L3 启用时展开的子区域: - `aiProviderId`:三选一 chip(openai/gemini/deepseek) - `aiApiKey`:输入框(secureTextEntry) - `aiBaseUrl`:输入框(默认值 placeholder) - `aiModel`:输入框(默认值 placeholder) - `aiEnabled`:Switch(总开关,控制 L3 的实际可用性) - [ ] **Step 5: 验证** `npm run typecheck` → 0 错误;`npm test` → 全绿 --- ### Task 5: AI 设置页清理 + settings 导航更新 **Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案) - [ ] **Step 1: settings/ai.tsx 改为 redirect** 不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。 - [ ] **Step 2: settings.tsx 入口文案** `src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变,但实际点击跳转已由 expo-router 自动处理(ai.tsx → redirect → automation),不需要改路由注册。 - [ ] **Step 3: 验证** `npm run typecheck` → 0 错误;`npm test` → 全绿 `grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转 --- ### Task 6: 编译 + 测试 + 终审 - [ ] **Step 1: Kotlin 编译** `cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL - [ ] **Step 2: 全量测试** `npm test` → 全绿;`npm run typecheck` → 0 错误 - [ ] **Step 3: 审计** ```bash grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出 grep -rn "fontFamily" src/app/automation/index.tsx # 无输出 grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出 ``` - [ ] **Step 4: 手工走查清单** 1. settings 页点「智能记账」→ 跳转到 automation 页 2. automation 页看到三个层级开关(L1 默认开 / L2 默认开 / L3 默认关灰) 3. 关闭 L1 → 仍可记账(走 L2 OCR) 4. 关闭 L2 → L1 失败不再调用 OCR 5. 开启 L3 → 提示配 AI Key → 配置后可用 6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装 7. L2 开关关闭再打开 → 已有模型直接可用,不再提示 8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化 --- ## 终审修订(已实施,以实际代码为准) **日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。 | # | 级别 | 问题 | 处理 | |---|---|---|---| | — | — | 无 Critical / Important 发现 | — | **偏差说明**: 1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。 2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底(modelDir 为空时走原路径),保证向后兼容。 3. `ensureOcrModels()` 在 `_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。