# homework-planet **Repository Path**: mat-labs/homework-planet ## Basic Information - **Project Name**: homework-planet - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-10 - **Last Updated**: 2026-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 作业星球 Homework Planet Android 端的儿童作业管理 App。Capacitor 8 + Vue 3 + Vite。默认走**云端模型(火山方舟 Agent Plan · deepseek-v4-flash)**, 没配 Key 就自动降到离线规则引擎;端侧小模型作为可选的离线兜底。 ## 已实现 | 能力 | 说明 | |---|---| | 一句话建任务 | 输入「明天交数学练习册第12页」→ 自动抽出科目 / 标题 / 目标用时(时间词只用来把标题洗干净,不记录截止时间) | | 拍照建任务 | 相机 → 系统 OCR(ML Kit)→ 文本 → 模型结构化。不加载多模态大模型 | | 语音建任务 | 录音(MediaRecorder)→ 语音转写 → 文本 → 模型结构化。转写可走**云端豆包 ASR(火山方舟)**或**端侧 Qwen3-ASR(离线)**,设置页可切 | | 过程计时 | 作业卡片上点「开始 / 暂停 / 继续 / 完成」,实时秒表 + 进度条,用时随任务一起落库 | | 任务详情页 | 点卡片正文进入详情页:完整的时间线、大号计时段、以及单项计时 | | 单项计时 | 把作业拆成「阅读理解 / 作文 / 生字词」等单项,**开始前点「开始」、读完点「结束」**,用时自动落账;被打断可暂停 / 继续,也可事后手动补录用时(如阅读用时) | | 目标用时提醒 | 距目标用时还剩 5/3/2/1 分钟、用满目标、超出 1/2/3/5/10 分钟逐点播报;App 切后台用系统通知兜底 | | 专注面板 | 正在做的那一门顶到最上面,大数字显示「距目标用时还剩多少」,一键暂停 / 完成 / 目标 +5 分钟 | | 每日鼓励 | 基于当天完成情况生成一句鼓励,模型不可用时用模板 | | 日报 / 周报 / 月报 | 完成率、目标达成率、分科目实际用时 vs 目标用时、连续打卡,可生成 AI 点评 | | 重复作业 | 每天 / 每周,完成一条自动 spawn 下一条 | | 每日提醒 | 固定时间本地通知(用户开启时才申请权限,不在启动弹框) | | 设备诊断 | 真实探测 WebGPU 可用性、实测端侧推理速度(字/秒),一键判断这台机器跑不跑得动 | | 任务编辑 | 详情页右上角「编辑」改标题 / 科目 / 目标用时 / 重复 / 备注,解析错了随时补救 | | 科目筛选 | 任务页顶部按科目横向筛选,带待办条数 | | 备份导出 | 导出 JSON 并唤起系统分享面板(可存网盘 / 发微信);导入时先预览再选合并或替换 | | 示例数据 | 设置页一键载入 9 条示例作业,覆盖超出目标、每日重复、做了一半暂停、已完成、带单项计时几种形态 | | 数据 | 纯本地(Android 走 SQLite,浏览器调试走 localStorage),无账号 | ## 作业过程跟踪与目标用时提醒 孩子点「开始」之后,这条作业就不再只是一个待办项,而是一个**跑着的过程**: ``` 待办 ──开始──▶ 计时中 ──暂停──▶ 已暂停 ──继续──▶ 计时中 ──完成──▶ 已完成(用时落库) │ └── 提醒:距目标 5/3/2/1 分 → 用满目标 → 超出 1/2/3/5/10 分 ``` ### 为什么没有截止时间 时间口径只留三个**事实**: | 字段 | 含义 | |---|---| | `firstStartedAt` | 首次点「开始」的时刻(暂停再继续不会刷新它) | | `elapsedSec` | 已结算的**净**用时(暂停期间不累加) | | `estimateMin` | 目标用时(分钟) | 由此推出:`实际用时 = elapsedSec + (现在 − startedAt)`,`剩余 = 目标用时 × 60 − 实际用时`。 ### 单项计时:把"阅读用时"单独记一笔 语文作业里最典型的痛点是:整份练习册 25 分钟,家长真正想问的是「阅读理解那一题读了多久」。 所以作业底下可以挂**单项计时**(`domain/subtask.ts`,代码里仍叫 `Subtask`),每条单项自己记一份时间: - 交互就一条直线:**开始前点「开始」,读完点「结束」**——「结束」= 停表 + 标为做完,一次点到位; 中途被打断就点「暂停」,回来点「继续」接着走; - 加单项也只剩一次点击:详情页底部的「+ 阅读理解」这类常用项**点一下就直接落一条**, 不必先填名字再点「添加」;要指定目标用时就自己写名字 + 分钟数; - 事后补录:孩子离线做完、没来得及计时,在「设置 → 手动补录用时」里**覆盖式**填上阅读用时即可; - **同一时刻整条作业只走一块表**:点某项「开始」会把任务级和兄弟项一起停下; - 单项只是把账记细了一层,`任务总用时 = 任务级 + Σ 单项`, 所以进度条、剩余时间、目标用时提醒、日报/周报/月报全都**自动**把单项的时间算进去, 不需要另起一套统计口径; - 勾选某项「做完了」只是清单状态,不会替家长把整条作业标成完成; - 「继续」接着**刚才停下的那一块表**走:任务级暂停过就续任务级,某个单项暂停过就续那个单项。 原来的 `dueAt`(“明天 20:00 交”)被拿掉了。截止时间是家长的一个排期承诺,不是孩子实际的时间事实—— 孩子 19:40 才开始,20:00 的截止就必然“来不及”,报告里的按时率只是在统计家长的排期。 换成目标用时之后,报告说的是「有没有在自己的目标用时内做完」,这才是孩子能控制的事。 一句话建任务时,「明天 / 晚上8点半」仍然会被解析出来,但只用来把标题洗干净,**不落成任何时间字段**。 ### 计时不靠"每秒加一" 任务上只存三个字段:`elapsedSec`(已结算的秒数)、`startedAt`(当前这一段的起点)、 `runState`。运行中的用时是**现算**的: ``` 用时 = elapsedSec + (现在 - startedAt) ``` 好处有三个:不用每秒写一次库;进程被杀、手机重启后重新打开,秒表还准;暂停时把这一 段结算进 `elapsedSec` 再把起点清空,逻辑只有一处。 ### 提醒节点 节点是**相对目标用时**的秒偏移(负数=距目标还剩多久): | 节点 | 秒偏移 | 播报 | |---|---|---| | 距目标 5 分 | -300 | 距目标时间还有5分钟,抓紧把最后几道题写完。 | | 距目标 3/2/1 分 | -180/-120/-60 | 还剩3分钟就到目标时间,收个尾吧。 …… | | 用满目标 | 0 | 已经用满目标时间啦,加把劲把它写完。 | | 超出 1/2/3/5/10 分 | 60/120/180/300/600 | 超出目标时间1分钟啦,加油写完它。 …… | 节点、文案、判定逻辑全在 `src/domain/timer.ts`(零依赖,可以直接跑冒烟测试)。 每条任务用 `remindedOffsets` 记账,播过的节点不会再播。设置页可以逐个开关。 **提前量必须小于目标用时**:目标 5 分钟的作业,“还剩 5 分钟”和“开始”是同一时刻, 这种节点会被自动去掉(`milestonesForTarget`)。 **只有"正在计时"的任务才提醒**——暂停了还催,就成了另一种唠叨;而且暂停时净用时也停住, “暂停 10 分钟”不会白白吃掉孩子的目标用时。 两个刻意的设计: - **迟到不补播**:锁屏期间可能一口气攒下好几个过时节点,全部补出来只会把人吵懵。 超过 60 秒的旧节点静默记账(`CATCH_UP_SEC`),只播"刚刚发生"的那个。 - **提醒有"可见版本"**:每次播报同时弹一个大字横幅。语音这条链路有可能哑掉 (没装中文语音包、媒体音量为 0、WebView 静音),听不见的时候抬头还得看得见。 ### 出声这条路,三级降级 | 通道 | 场景 | 说明 | |---|---|---| | `native` | Android 真机 | 自写的 `TtsPlugin`(`android.speech.tts`),离线可用、零第三方依赖 | | `web` | 桌面浏览器调试 | 浏览器 `speechSynthesis` | | `tone` | 兜底 | WebAudio 提示音 + 大字横幅 | **为什么不直接用浏览器的 `speechSynthesis`**:Android System WebView 里这个对象是存在的, 但设备上 `getVoices()` 常常返回空数组,`speak()` 一声不吭——对一个唯一目的就是出声的功能, 等于没有。原生的 `TextToSpeech` 是系统自带能力,中文语音包在国行机型上基本都预装。 两个踩过的坑记在这里: | 坑 | 现象 | 处理 | |---|---|---| | Android 11+ 包可见性 | 不声明就"看不见"语音引擎,一播报就报引擎初始化失败 | 清单里加 `` | | 引擎异步初始化 | 启动瞬间问 `isAvailable` 会得到 false,前端误判成"这台设备不支持" | 插件内部排队:没就绪的 `isAvailable` / `speak` 先存着,初始化回调里统一放行 | ### App 被切走的时候 孩子做作业时大概率会切出去查资料、锁屏、或者按 Home,这时 WebView 的定时器会被系统挂起, 语音就哑了。所以**开始计时的那一刻,顺手把节点排进系统通知**(`services/task-reminder.ts`), 让 Android 自己守着;暂停 / 完成 / 删除 / 改目标用时都会重排或撤销。 排期基准是“距用满目标用时还剩多少秒”(净用时口径,暂停不计)。 系统通知念不了 TTS,只能响铃 + 显示文字,所以它是兜底而不是主路。 另外计时期间会申请 Wake Lock 保持屏幕常亮(暂停或完成立刻释放); 浏览器不支持就静默跳过,还有通知兜底。 > 通知权限只在设置页手动打开"系统通知兜底"时才申请——启动就弹权限框是这套设计明确避开的。 ## 三种录入,收敛成同一句话 拍照、语音、文字三个入口,最终都变成一句纯文本,再交给同一套结构化逻辑: ``` 文字 ──────────────────────────────┐ 拍照 → 系统 OCR(ML Kit 中文)──────┼→ 一句话 → 结构化 → 任务草稿 语音 → 云端豆包 ASR(火山方舟)─────┘ ``` 刻意不让任何一路去加载多模态大模型:相机交给系统 OCR、语音交给云端专用 ASR, 抽出来的文本再给文本模型做结构化,又快又省。 ## 语音识别(火山方舟) 语音转写走 Agent Plan 里的**流式语音识别**(`doubao-seed-asr-2.0`): - **接口**:`wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_nostream` (单流:音频发完再统一返回高精度结果。这个 App 是「说完一段再解析」,不需要边说边出字) - **鉴权**:`X-Api-Key`(专属 API Key)+ `X-Api-Resource-Id: volc.seedasr.sauc.duration` - **音频**:16kHz / 16bit / 单声道**裸 PCM**,按 200ms 分片走二进制帧发送 ### 这一层为什么是原生代码 方舟的语音模型只提供 WebSocket 接口,而鉴权**全在握手头**上;浏览器的 `WebSocket` 按 W3C 规范不允许自定义握手头,纯前端发不出去。所以连接与协议由 `android/app/src/main/java/com/matlabs/homeworkplanet/ArkAsrPlugin.java`(OkHttp)完成, 前端只负责把 PCM 递过去。 另外两条「看起来更省事」的路都实测排除了,记在这里避免重走: | 尝试 | 结果 | |---|---| | `chat/completions` 的 `input_audio` | `audio input is not supported by this model`(套餐内的语言模型不接受音频) | | 把鉴权塞进 URL query | `get resource id empty`(只认请求头) | 帧协议上还踩了一个坑:**音频包必须用 flags=0**,序号由服务端自动分配。声明成「带序号」的话, 服务端会把 PCM 前 4 个字节当成序号,报 `autoAssignedSequence (2) mismatch sequence in request (2325)`。 配置在「设置 → 语音识别」。留空则语音入口提示去配置,其余功能不受影响。 `node scripts/cdp-voice-check.ts some.wav` 可绕开麦克风直接验证整条链路。 ## 本地语音识别(端侧,离线) 设置页「语音识别引擎」可二选一:**云端方舟**(音频上传)或**本地端侧**(不出设备)。 Android 上默认走本地。 模型是 **Qwen3-ASR**(经 **llama.cpp 的 `libmtmd`** 在手机上跑,完全离线), 规格在设置页可选,目录见 `src/services/asr-models.ts`: | 规格 | 下载量 | 说明 | |---|---|---| | **0.6B · Q8_0**(默认) | 约 1.0 GB | 体积与速度平衡最好 | | 1.7B · Q8_0 | 约 2.5 GB | 更准,但明显更慢、更吃内存 | | 0.6B · bf16 | 约 1.8 GB | 原精度,提升有限 | | 1.7B · bf16 | 约 4.6 GB | 体积最大,收益很小 | > 官方只发布了 **Q8_0 与 bf16** 两种精度 —— 想上更小的 Q4,得自己有地方托管(ModelScope 上没有)。 ``` 录音(WebView MediaRecorder)→ 16k 单声道 PCM → 插件补 44 字节 WAV 头写进 cacheDir → JNI(libmtmd)读音频 → 编码 → 解码 → 文本 → 交给同一套结构化链路 ``` ### 模型不随包内置,点「启用模型」在手机上现下 `llama.cpp` 要真实文件路径 + mmap,而 APK 里的 assets 在 zip 内不是路径 —— 无论如何都要落盘。 既然要落盘,与其把几百 MB 塞进 APK 再拷一遍(手机上占两份、安装包还大几百 MB), 不如直接从 **ModelScope** 拉(`ggml-org/Qwen3-ASR-*-GGUF`,国内可达、CDN 不慢,HF 不通)。 `LocalAsrPlugin.prepare(spec)` 的顺序是:**已有 → assets(若某次构建内置了)→ 下载**。 幂等:同一规格重复调用秒过;**换规格会先释放旧上下文再重载**(不会两份模型同时占内存)。 拷贝与下载都是原子的(先写 `.tmp` 再 rename),中途被杀不会留下半个模型; 每 8MB 通过 `progress` 事件回报进度,设置页有进度条与磁盘占用。 旧规格的文件**不会自动删**(可能还要切回去),要腾空间用设置页的「删除已下载模型」 (对应 `deleteModels()`)。 > 想改回内置:`node scripts/prepare-asr-models.ts` 把模型放进 `android/app/src/main/assets/models/` > (该目录已 gitignore),代码会自动优先走本地拷贝,不用改一行。 ### 原生层怎么构建 llama.cpp 是 177MB 的第三方仓库,**不进本仓库**。链路由脚本固化: ```bash node scripts/build-native-asr.ts # 复用已有 llama.cpp 构建 node scripts/build-native-asr.ts --rebuild-llama # 连 llama.cpp 一起重编 ``` 它做四件事:编 llama/mtmd(arm64)→ 编 JNI(`android/app/src/main/cpp/`)→ strip → 把 6 个 `.so` 复制到 `android/app/src/main/jniLibs/arm64-v8a/`(AGP 自动打进 APK,约 20MB)。 用 `LLAMA_CPP_DIR=` 可指定 llama.cpp 源码位置。 三个踩过的点,改动前先看: | 坑 | 现象 | 处理 | |---|---|---| | `mtmd` 在 `tools/` 下 | 安卓构建报 `No rule to make target 'mtmd'` | 必须 `-DLLAMA_BUILD_TOOLS=ON` | | 音频文件加载 | 以为要 ffmpeg | `mtmd_helper_bitmap_init_from_file()` 用的是**随 mtmd 编进来的 miniaudio**,wav/mp3/flac 都能解并重采样,无需 ffmpeg | | chat template | 照搬 `llama-mtmd-cli` 会引入整条 llama-common | 用 `--verbose` 抓到真实 prompt 是 `<\|im_start\|>user\n<__media__>Transcribe this audio<\|im_end\|>\n<\|im_start\|>assistant\n`,**手拼即可**,省掉 jinja 依赖 | > Qwen3-ASR 在 llama.cpp 里仍标 **experimental**;音频编码器需要 1–8s 加窗注意力,长音频质量未验证。 ## 语言模型(火山方舟 Agent Plan) 结构化用的文本模型是 Agent Plan 里的 **`deepseek-v4-flash`**,与语音识别**共用同一把专属 Key**: - **Base URL**:`https://ark.cn-beijing.volces.com/api/plan/v3`(OpenAI 兼容) —— 控制台明文警告**不要**用 `/api/v3`,那条路会产生额外计费 - **模型名**:`deepseek-v4-flash` - **思考开关**:它是带推理的模型,默认先吐一大段 `reasoning_content`。实测同一句作业结构化, 带思考 **15.1s**、关掉思考 **6.5s**,抽取结果质量没差别,所以请求里统一带 `thinking: { type: 'disabled' }` ### 为什么这一层也走原生 方舟的 CORS 预检 `access-control-allow-headers` 只放行 `Origin, Content-Length, Content-Type`, **不含 `authorization`**。浏览器里任何带 `Authorization: Bearer` 的 `fetch` 连预检都过不去 (实测 `TypeError: Failed to fetch`,179ms 直接毙),也就是说这条路在 WebView 里**不可能**走通。 所以 `cloud-provider.ts` 用一个 `post()` 收口:在原生平台上走 `CapacitorHttp.request()` (原生层发请求,不受同源策略约束),桌面浏览器调试时退回 `fetch`。验证方式很直接—— `scripts/cdp-cloud.ts` 能在 WebView 里跑通,就证明它确实走了原生通道。 配置在「设置 → 语言模型(云端)」。Key 默认从语音识别那边继承(同属一份 Agent Plan), 也可以手动填,或点「用 DeepSeek 官方」切到直连接口。 ## 三级引擎,自动降级 | 引擎 | 体积 | 何时使用 | |---|---|---| | 云端模型 | 0 | **默认**。方舟 Agent Plan 的 `deepseek-v4-flash`,一键填入;任何 OpenAI 兼容服务都能用 | | 离线规则引擎 | 0 | 没配 Key 时自动接管。中文关键词 + 正则,46 项冒烟测试覆盖,不联网、不下载 | | 端侧小模型 | 589 MB | 可选离线兜底,默认**不进 APK**。Transformers.js + Qwen3-0.6B | **为什么默认推云端**:端侧 0.6B 在 Android WebView 的 WASM 上实测生成 220 token 要数分钟, 交互上不可用。所以 `auto` 的顺序是云端 → 端侧 → 规则引擎;等端侧优化到位再把顺序调回来。 **端侧要不要内置**:模型文件留在 `public/models/`,默认不打包,APK 约 67MB。 需要离线内置时: ```bash npm run fetch-models # 拉模型(604MB,不入库) node scripts/build-android.ts --with-models # 打进 APK,体积涨到约 500MB ``` | 模型 | 体积 | 用途 | |---|---|---| | Qwen3-0.6B(q8) | 589 MB | 一句话 → 结构化任务 | > 语音不再走端侧模型:Whisper 在 WebView 上同样太慢,已改用方舟云端 ASR(见上一节)。 > 实测过两个坑:Qwen3 的 int4 变体用了 `GatherBlockQuantized` 算子,ORT Web/WASM 不实现, > 只能用 `onnx/model_quantized.onnx`(q8);它的 chat template 是独立的 `.jinja` 文件, > 而 transformers.js 只认 `tokenizer_config.json` 里的 `chat_template` 字段, > `fetch-models` 会自动注入,否则模型能加载、一生成就报 `chat_template is not set`。 > 模型文件不入库,用 `npm run fetch-models` 拉取(ModelScope 优先,国内可达)。 ## 目录结构 ``` src/ ├── domain/ 零依赖领域层:任务模型、重复规则、计时与提醒节点(timer.ts)、统计、备份序列化、示例数据 ├── ai/ │ ├── provider.ts LlmProvider 统一接口 │ ├── heuristics.ts 中文时间/科目/时长启发式(规则引擎与模型后处理共用) │ ├── rule-provider.ts 离线规则引擎 │ ├── wasm-provider.ts 端侧小模型(Transformers.js) │ ├── transformers-env.ts 内置优先的模型寻址 │ ├── cloud-provider.ts 云端模型(OpenAI 兼容,原生通道发请求) │ ├── ark-llm.ts 火山方舟 Agent Plan 的语言模型预置(deepseek-v4-flash) │ ├── ark-asr.ts 云端语音识别(转发给原生 ArkAsr 插件) │ └── gateway.ts 能力探测 + 路由 + 降级 ├── data/ SQLite 仓储(含列迁移)+ localStorage 降级 ├── services/ OCR、语音转写、PCM 编码、本地提醒、设备能力探测、备份导入导出 │ voice.ts / native-tts.ts 语音播报(原生 TTS → 浏览器 → 提示音) │ reminder-engine.ts 目标用时提醒派发(全局一份) │ task-reminder.ts 切后台时的系统通知兜底 │ wake-lock.ts 计时期间保持屏幕常亮 ├── store/ 任务与设置的响应式状态 └── ui/ 四个页面 + 专注面板(FocusPanel)+ 提醒横幅(ReminderToast) ``` 原生的两个插件在 `android/app/src/main/java/com/matlabs/homeworkplanet/`: `ArkAsrPlugin.java`(方舟语音识别)、`TtsPlugin.java`(系统语音合成), 都在 `MainActivity.onCreate` 里于 `super.onCreate` 之前注册。 ## 验证 ```bash npm run verify # 类型检查 + 冒烟 + 挂载校验(下面三项) npm run typecheck # vue-tsc 零错误 npm run smoke # 181 项:领域模型 + 计时与目标用时节点 + 规则引擎 + 模型输出解析 + 统计 + 备份容错 + 示例数据 npm run mount # 53 项:在 jsdom 里真实挂载组件,验证渲染与交互 npm run e2e # 真浏览器跑一遍目标用时提醒(需先 npm run build) ``` `smoke` 覆盖纯逻辑,`mount` 覆盖组件行为——后者用 `jsdom` 真实挂载 Vue 组件, 断言点击开始会触发 start、计时中显示秒表、暂停后按钮变「继续」、提醒横幅按节点变色、 点卡片会触发编辑、删除需二次确认、Esc 可关闭等,并检查控制台无 Vue 运行时错误。 `e2e` 补的是"引擎到点真的会开口"这件事:起 `vite preview` + 无头 Chrome, 往 localStorage 塞一条「目标 1 分钟、已用 55 秒、正在计时」的作业,刷新后等横幅自己冒出来, 再确认节点已经记账(同一节点只播一次)。零额外依赖,只用 Node 自带的 WebSocket 直连 CDP: ```bash npm run build && npm run e2e ``` ## 浏览器调试 ```bash npm install npm run dev # http://localhost:5173 npm run verify # 类型检查 + 冒烟 + 挂载校验 ``` 浏览器里拍照识别不可用(提示直接粘贴文字);导出走 Blob 下载而非系统分享,其余功能完整。 ## 打包 APK 需要 **JDK 21** 与 **Android SDK**(AGP 8.13 要求 JDK 21,SDK 装在 `/Users/sunxinhe/data/sdk`): 一条命令跑完「构建 → 同步 → 打包 → 装机」(脚本已内置 JAVA_HOME / ANDROID_HOME): ```bash node scripts/build-android.ts # 默认不含模型,APK 约 68MB,装完自动启动 node scripts/build-android.ts --with-models # 把 604MB 模型一起打进去,约 500MB node scripts/build-android.ts --no-install # 只出包不装机 ``` 想手动分步也可以: ```bash npm run build && npx cap sync android cd android && ./gradlew clean assembleDebug # 产物 app/build/outputs/apk/debug/ ``` > 换过 assets(尤其是模型)一定要带 `clean`,否则增量构建会把旧文件留在 APK 里, > 实测能让包从 586MB 虚胖到 1.05GB。上面的脚本已固定带 clean。 > 国内网络注意:`dl.google.com` 不通,Gradle 依赖已通过 `~/.gradle/init.d/mirrors.gradle` > 切到阿里云 / 腾讯云镜像。命令行构建不需要改项目文件。 ### 签名:仓库内置调试签名(别乱动) Android 的**升级安装要求新旧包同签名**,签名一变就必须先卸载(本地作业数据全丢)。 所以签名不能依赖机器本地的 `~/.android/debug.keystore` —— 它被清掉、或换台机器, 签名就悄悄变了,用户装更新时报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match`。 现在这把钥匙**随仓库走**: | 东西 | 位置 | 入库? | |---|---|---| | keystore | `android/app/hp-debug.keystore` | ✅ 入库(调试密钥,口令公开) | `app/build.gradle` 的 `signingConfigs.debug` 显式指向它,`debug` 构建类型用它签名 (`./gradlew :app:signingReport` 可确认)。口令是公开的调试口令,**仅用于内部调试包,不是生产密钥**。 > ⚠️ **不要替换、不要删除这个 keystore 文件**:换掉就等于换签名,已装 App 的用户必须卸载重装、 > 本地作业数据全丢。确需更换时,先让用户导出备份再装。 > 历史:曾用过一套「私钥不入库 + 出包后核对指纹」的固定签名(`android/keystore/`、 > `android/expected-signing-cert.txt`),已废弃。`android/keystore*` 仍在 `.gitignore` 里, > 防止残留的私钥/口令被误提交。 ## 注意事项 - **包体积**:不含模型约 68MB(主要是 ML Kit 的 OCR 模型与 ONNX Runtime 的 WASM 运行时); 加 `--with-models` 内置模型后约 500MB。模型文件**故意保持压缩**——实测压缩状态下 WebView 从 assets 读取完全正常(Content-Length 准确,50MB 用时 164ms / 304MB/s), 没必要为了随机访问牺牲几十 MB。设置页「释放」可清掉已载入内存的模型。 - **模型不入库**:`public/models/` 已 gitignore(604MB)。clone 后先跑 `npm run fetch-models`, 脚本 ModelScope 优先、hf-mirror 回退,支持断点式跳过已完整下载的文件, 并会把 `chat_template.jinja` 注入 `tokenizer_config.json`。缺失时 App 自动回退规则引擎,不会崩。 - **WebGPU**:Android System WebView 上常见「`navigator.gpu` 存在但 `requestAdapter()` 返回 null」。`WasmProvider` 用 `requestAdapter()` 真实探测(带 5 秒超时),拿不到就退 WASM,无需手动干预。设置页「设备诊断」可看到探测结果和实测速度。 - **儿童隐私**:默认全本地。照片只在本机交给系统 OCR,语音转写需在设置页填了火山方舟 Key 才会上传音频;没配 Key 时语音入口直接提示去配置,不会偷偷外发。 - **换机迁移**:没有云端账号,换机只能靠「导出备份」拿到的 JSON 文件。导出走系统分享面板,存网盘或发到自己微信均可;导入时默认「合并」,不会覆盖现有数据。 - **备份兼容性**:导入会容错读取——非法科目降级为「其他」、非法时间丢弃、缺 ID 自动补、重复 ID 重新分配,并逐条给出警告。裸数组和他应用的备份也会尝试读取。 ## 版本 Node 22.22.2 · Capacitor 8.5.1 · Vue 3.5.42 · Vite 8.2.2 · Transformers.js 4.2.0