# 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<string>` → 返回模型目录绝对路径。

新建 `src/services/modelDownloader.ts`：
```typescript
export async function ensureOcrModels(): Promise<string> {
  // 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` 作为安全网。
