# Mika 架构
这份文档按代码现状描述 Mika 的整体结构,方便找代码。还没做的、做了一半的写在 [`roadmap.md`](roadmap.md)。
路径缩写:`core/…` = `core/src/main/kotlin/com/wangzan101/mika/core/`,`app/…` = `app/src/main/java/com/wangzan101/mika/`。
## 1. 模块与分层
仓库有两个 Gradle 模块:
- `:core`:纯 Kotlin(JVM),不依赖安卓。主循环、工具、能力的平台无关部分、上下文、核查、记忆、知识、触发器、运行记录都在这里,可以在电脑上用单测和假模型跑。提示词、内置知识与技能是它的资源(`core/src/main/resources/`)。
- `:app`:安卓宿主。界面、前台服务、虚拟屏、无障碍、内置手机助手、WebView、沙箱、文件存储,以及 `:core` 里各个接口的安卓实现。
```
来源 对话界面 │ 定时/条件触发 │ 父任务(子任务)
└──────────┬──────────┘
TaskService(前台服务,安卓宿主)
TaskCenter(每个对话一个 Run、并行上限、排队)· RunStore · Outbox
│
TaskRun ── 一次运行:装能力、建 AgentLoop、收尾
│
AgentLoop(只调度)← RunInbox(插话、回答、确认、交回、取消)
ModelCaller · ContextBuilder/ContextCompressor · TurnPolicy/StopRules
ToolDispatcher → ActionExecutor(唯一执行入口)
│ CapabilityProvider(核心不认识具体能力)
phone web sandbox (mcp:骨架)
```
`:core` 里包之间的依赖方向(`PackageDependencyTest` 检查):
- 能力(`core/cap/<能力>/`)依赖核心,核心不引用任何能力,能力之间也互不引用;只有装配层(`core/assembly/`)同时认识几种能力。App 里 `cap/` 下各能力的目录同样互不引用。
- 工具引擎(`core/tool/`:`ToolDispatcher`、`ToolContext`、`AgentTool` 等)不引用界面状态(`core/uistate/`)。引擎只经 `SurfaceLayer`(`core/tool/SurfaceLayer.kt`)用到有界面的那一层,接口里只有屏的 id、工具、参数和结果;实现是 `Interfaces`,由装配层开局造好、放进工具上下文,有界面的工具经 `core/uistate/ScreenContext.kt` 里的扩展从上下文取读屏状态。
## 2. 任务与运行
### Run
一次运行由 `RunSpec`(`core/run/RunContracts.kt`)描述:`id`、`goal`、`threadId`(对话)、`priorTurns`、`provenance`、能力子集 `capabilities`、`readOnly`、`parent`、`stepBudget`。
- `RunProvenance`(`core/run/RunProvenance.kt`)记这次运行从哪来、用户在不在:`RunOrigin` = Ui / Channel / Trigger / Mcp / Child;`Attendance` = Present / Reachable / Unattended。
- 运行阶段 `RunPhase`:Queued、Preparing、Running、WaitingUser、Parked、Paused、Finishing、Aftercare、Ended、Interrupted。
- 「这一步谁发起」是另一回事,见 §4 的 `Origin`。
### 任务中心
`TaskCenter`(`app/task/TaskRegistry.kt`,由 `TaskService` 持有)管运行的启动、排队与收尾:
- 每个对话同时只有一个 Run。同一对话里任务进行中用户又发消息,不另开任务,而是作为插话交给正在跑的 Run(`TaskService.begin`)。
- 不同对话的任务可以并行,上限见 §8;超过上限的进先进先出队列,有 Run 结束时依次补上(跳过对话里已有 Run 的)。并行开关关着(默认)时,任务进行中界面不能切换对话(`ChatViewModel.canSwitch`、侧栏 `ChatDrawer` 里别的对话不可点并有提示),界面发起的另一个对话的任务也不进队列、直接忽略(`TaskService.begin`);只有定时任务和留下的消息会排队。
- 定时任务到点时,如果原对话正有任务在跑,就在这个对话里排队等它结束(`queueInConversation`),不当成插话,也不丢。
- 运行状态写进 `runs-v3.json`(`app/task/FileRunStateStore.kt`,原子替换写入)。
- `app/task/TaskCenterState.kt` 只是界面要显示的状态(运行列表、并行上限、插话状态等)。
### 收件箱与插话
运行中从外面进来的东西都经 `RunInbox`(`core/run/RunInbox.kt`),种类是 `RunInput`:Steer(插话)、Redirect、Answer、Confirm、HandBack、Cancel。
- **插话是补充**:做过的一概保留。每句插话都撤掉此刻还在等的模型请求(`AgentLoop.routedOrRedirect`;撤掉的那次不写进历史),接在已有历史后面重问,发过的消息一字不改,请求前缀照样只增不变。已经进了执行入口的工具调用照常做完、结果照记,下一次问模型之前再接上插话(`AgentLoop.appendSteers`)。Steer 和 Redirect 走同一条路。
- **插话什么时候进上下文**:`AgentLoop.appendSteers` 在每一步调模型之前取收件箱;模型给出不带工具的回答时先看一眼,有新插话就继续干。回答交出之前(核对、整理来源之后)用 `RunInbox.closeUnlessSteered` 在同一把锁里看和关:还有插话就不收尾、带着它接着做,没有才关上收件箱,此后的话才成为下一件事。模型自己调 end_not_done 时刚到的插话同样让它接着做;守卫叫停、出错照样结束。
- **等待提前结束**:收件箱给出「有插话还没交给模型」的信号(`core/run/SteerSignal.kt`),装在整个运行的协程上下文里。撤掉也什么都不丢的调用经 `waitOrSteer` 包起来,插话一到就提前结束:纯等待和只问看图模型的工具(声明 `AgentTool.yieldsToSteer`:wait、phone_wait、phone_event、web_wait_for、describe、find_visually,由分发器统一包)、`phone_run` 里的等字、`do_steps` 按条件等界面的每一段、网页换页之前的停留(`PageDwell`)、同一网站或 App 里两次对外动作之间的间隔(`ActionExecutor`)。被截断的调用报 `skipped_for_new_message`、这次什么也没做;在 `do_steps` 里这一步和后面的子步一起跳过。动作做完之后等界面安静、判效果的那一下不截断(它给的是动作的结果)。模型调用的重试等待本来就在模型请求里,随它一起撤掉。
- **节奏保护不丢**:停留和间隔被截断时,动作本身不做(工具结果说明为什么、要的话再调一次)。两者都按时刻算(间隔从上一次对外动作做完算起,停留从这一页来的时刻算起),没停够的部分不算停过,下一次换页或对外动作之前照样补足余下的时间;插话只是让模型现在就能想,不会让下一次动作来得更快。
- **一次多步途中**:`do_steps` 在两步之间发现有插话,就停在那里,结果写明「为处理新消息而跳过」(`skipped_for_new_message`),不算失败(`core/agent/BatchRunner.kt`)。某一步做完正在按条件等界面时有插话,等待也马上停下(那一步的 `wait.stopped` 是 `new_message`),后面的子步照样跳过。
- **界面上马上有回音**:`TaskRun.steer` 送进收件箱成功就把这句话标成「已收到,下一步处理」(`SteerState.Received`),交给模型时改成「已加入当前任务」;对话页和悬浮胶囊的对话卡片都在这句话下面写这行小字(`steerLabel`)。
- **没来得及处理的插话**:运行结束时还没消费的插话,如果用户没按停止,就成为这个对话的下一个任务;按了停止的丢掉(`TaskRun.handoffUnconsumed` 只管收尾时未消费的)。运行已关闭后才到的插话,由 `TaskRun.steer` 收不进收件箱时 `deferMessage`,或 `TaskService.begin` 发现任务已收尾时排成下一个任务。
- **停止**:Cancel 是控制信号,不在插话后面排队。`TaskRun.cancel` 同时取消子任务;接管中就结束接管;排队中的直接移出队列。
- **停止后的交代**:界面当场显示已停止,输入框空出来;随后补上几行交代当作这一轮的回答(做过什么、看到了什么、成批的事发出了几次和是哪些、哪些没做完),结局照旧是取消(评测、结果分类、学习都按取消算)。`TaskService` 收尾时先把运行编号登记进 `TaskCenterState.summarizing`(界面据此在「已停止」下面写「正在整理已做的事…」),记下「已取消」那一轮,再在进程级作用域里由 `TaskRun.stopSummary` 写,写好用 `ConversationStore.updateTurn` 改进那一轮,写好或写不成都拿掉登记;不挡着收尾、下一件事和再点停止。写法在 `StopSummary`(`core/agent/StopSummary.kt`):只读这次的运行记录(活动、那一步的用意、最近读到的两段、停下时写了一半的字,有上限,不带画面),请一次模型(轻量模型优先,出错换主模型,不给工具,总共 12 秒),没模型、出错、超时就按记录直接拼(`StopSummary.plain`)。对外动作(发送、提交)按活动上的 `outward`(`ActionExecutor` 照不可逆判定标上)数。什么也没做、也没写出字的只说已停止。下一轮的上文里写成「你中途停止了这次任务,停止前:…」(`Conversation.context`),「继续」知道从哪接着做。
### 送达
结果、提问、通知经 `Outbox`(`core/run/Outbox.kt`)送出:先记下要发的,再发。状态 Pending → Attempting → Delivered / Failed / Uncertain;同一个送达编号只送一次;发到一半结果不明的标为 Uncertain,不自动重发。记录在 `outbox-v3.json`(`app/task/FileOutboxStore.kt`)。
目前唯一的渠道是 `LocalChannel`(`app/task/LocalChannel.kt`):本机界面加系统通知。渠道接口 `Channel`、`ChannelGateway`(主人身份、提问编号对应、付款确认只能在能确认的渠道上做,`Channel.confirms`)在 `core/run/` 里已定义,还没有别的渠道接入。
### 进程重启后
- `RunRecovery`(`app/task/RunRecovery.kt`)是运行善后的单一入口,对象是「中断了、还没告知过用户」的运行。两个时机:进程重启后,`MikaApp.onCreate` 一起来就调 `recover`(`TaskService.onCreate` 也调,但进程里只有第一次生效,后到的什么都不做);任务服务被销毁而进程还在时,`TaskService.onDestroy` 标中断后当场调 `settleInterrupted`,不等下次启动(善后用 App 级作用域,服务没了也做完;启动时的扫描再接住当场没做完就被杀的)。标记在调用里同步做完(`RunStore.interruptUnfinished()` 把没结束的运行标为 Interrupted,此刻在跑的运行经 `live` 排除),善后在后台做:回复已存但没送达的补送一次(注明是恢复的);送达状态不明的不重发,只提示「上次结果可能已送达」;没有回复的,发「运行中断,可在对话里重新发起」的系统通知,并往对话记一轮失败状态的记录「任务中断了,可以重新发起」(和其他失败结果同一套,界面上显示为未完成的一轮)。**「已告知」不另设字段,看送达账本**:有 `interrupted:<运行>`、`uncertain:<送达>`、`failed:<送达>` 任一条提示记录就不再处理;回复送达了的运行转为 Ended。所以重复调用、两个时机撞上都只告知一次。**从不从中间续跑**:屏幕状态回不去。
- `TaskRecovery`(`app/task/TaskRecovery.kt`,App 启动时运行)恢复的是系统状态而不是运行:任务期间静音的 App 恢复声音,任务借用过的剪贴板还给用户。
- 子任务记录在加载时把 Running 改为 Unknown,不重跑(`app/task/FileChildRunStore.kt`)。
- 定时任务窗口的处理见 §10。
## 3. 主循环
`AgentLoop`(`core/agent/AgentLoop.kt`)只做调度,具体判断各在一个类里:
| 类 | 做什么 |
|---|---|
| `ModelCaller` | 组请求、调模型、流式输出;压缩与补回、临时错误重试、上下文超长重试、大小模型分流 |
| `RetryPolicy` | 429、5xx、断网有限重试(最多 3 次,指数退避,每个任务总等待有上限) |
| `ToolDispatcher` | 按名找工具、持有证据账本、拼各能力的提示词段落 |
| `TurnPolicy` | 进度检查(仅有步数上限时)、空回复或被截断时提醒一次、该跟进时提醒一次、回答换了语言时退回一次、最后一轮只给 `end_not_done`(仅有步数上限时) |
| `StopRules` | 什么时候结束:放弃、守卫叫停(重复同一动作、没有进展、反复读、失败太多、沙箱打转)、模型失败、步数用完(仅有步数上限时) |
| `BatchRunner` | `do_steps`:一次给 1–20 个已知动作,逐个执行、逐个核对;一步可带等待条件(`expect` 出现、`expect_gone` 消失、`expect_app` 前台 App 或页面地址、`wait_stable: true` 界面稳定,`timeout_s` 默认 8 秒、最多 20 秒),做完由代码读屏等到成立再做下一步,不再为「还在加载」问模型;弹框、别的包的窗口盖上来、换了 App 时提前停,结果里写 `wait`(`met`、`stopped`、`waited_ms`)。等待本身在 `core/uistate/UntilWait.kt`,手机、网页共用,经端口的 `awaitChange`(无障碍事件 / 页面变动)醒来;等的时间在运行小结里算等待,不算操作 |
| `IntentGate` | 对话界面发起的任务第一次要用非查阅类工具时,判断用户是在聊天、要查信息还是要办事;判为聊天就不带工具重答。判断不出(调用失败,或回复不是三个词之一)时照主模型原来的决定执行;每次运行只判断一次(`AgentLoop.assessIntent`) |
| `StuckAdvice` | 卡住时在工具结果里加建议;第二次卡住提示可以放弃 |
| `NoticePolicy` | 看完一个界面后决定要不要补给模型对应 App/网站的知识 |
| `GuideRecommender` | 开局按任务挑出最多 3 条知识/技能索引和至多一份个人技能 |
| `RouteReplay` | 打开「照着上次的做法做」时,在第一次调模型前回放匹配的成功路线(见 §9) |
| `UserExchange` | 追问、确认、接管这三种要用户参与的交互 |
正常任务不设步数上限(`TaskRun` 里 `AgentLoop.UNLIMITED_STEPS`),所以进度检查、最后一轮、步数用完这几项只用于评测(`EvalHooks.maxSteps`)和子任务(15 步,`SpawnAgent`)。
### 结果判定
- **没有「完成」工具**:模型不带任何工具调用的回复就是回答;带工具调用时附带的文字当作过程说明显示。
- **`end_not_done`**(`core/general/OutcomeTools.kt`):做不成或只能做一部分时用它结束,`message` 写清已确认做了什么、卡在哪、现在界面上是什么;`subtype` 是 `blocked`、`login_incomplete`、`captcha_incomplete`、`route_failed`、`device_unavailable`、`interaction_failed` 之一。
- **交回答之前**:空回复或被截断提醒一次;用户刚回答了什么而 Agent 什么也没做,提醒一次;最后一次改变界面的动作没读回结果,提醒一次。回答的正文明显不是用户最近那句话的语言(`ReplyLanguage` 按字母所属的文字数,汉字按字、其余按词,引号括号里的原文和只列名字的行不算),退回一次让模型整段改写成用户的语言,改写后还不对照样交出。
- **来源由系统生成**:`AnswerSources`(`core/verify/AnswerSources.kt`)从证据账本 `EvidenceLedger` 里挑出支持回答的观察(最多 6 条),作为回答下面的「来源」;不由模型自己写。
- **后台结果分类**:回答送达后,`TaskRun.settleOutcome` 在后台用 `OutcomeJudge`(`core/agent/OutcomeJudge.kt`,优先用轻量模型,30 秒超时)把这次运行分成 `RunOutcome` 的 Done / Partial / NotPossible / Unknown。它不改回答,结果写进运行记录和对话记录;定时任务据此标记这一次是否失败。学习与回放只认 Done(见 §9)。
- **独立核查**:`Grounding`(回答里的数字、编号、引文在读过的内容里有没有,算式对不对)和 `Verifier`(独立的核查模型)都在 `core/verify/`,由 `reviewFinalAnswer` 开关控制。产品目前关着(`TaskRun` 里 `reviewFinalAnswer = false`),所以回答都记为「未核实」,见 roadmap「已知问题」。
## 4. 唯一执行入口与工具
- **唯一入口**:所有改变外部世界的动作都经 `ActionExecutor`(`core/action/ActionExecutor.kt`)。`ToolDispatcher.execute` 要一个 `ActionPass`(`core/tool/ActionPass.kt`),它的构造函数是 internal,只有 `ActionExecutor` 能造,从编译层面杜绝绕过;只读调用走 `executeRead`,不是只读的会被拒绝。执行顺序固定:守卫 → 锁屏检查 → 不可逆动作检查与同意 → 执行 → 动作后核对 → 记录。
- **对外动作之间的间隔**(`core/action/OutwardPacing.kt` 纯逻辑,`ActionExecutor` 照它等):不可逆判定认出的发送、提交(含一点就联系别人、投申请的按钮,如「立即沟通」「打招呼」「投递」「Say hi」「Easy Apply」)在同一网站(按可注册域名)或 App(按包名)里连着做时,第一次不等,之后和上一次至少隔 8–20 秒随机的一段,最近 30 分钟里做得越多上下限各加 0.5 / 1 秒,至多 30 秒;从上一次做完算起,中间读页面、想下一步的时间算在内,换不换页都算(换页的停留另由网页的节奏管)。手机、网页同一个判定、同一套间隔;自主执行时判定照样跑来认对外动作(按模型手里那一屏,不另读屏),只是不拦。等在判定之后、动作开始之前,任务还能取消;等了多久记进这次调用的 `waitMs`,不算进操作用时。只由 App 的任务装上(`AgentLoop(outwardPacing = …)`),核心测试默认不等。
- **这一步谁发起**:`Origin`(`core/action/Origin.kt`)= Model / Batch / Script / Replay / McpTool,各有策略(能否用用户的同意放行付款、遇到守卫警告是否停)。复合工具(一次多步、沙箱里的 `mika phone`、网页脚本)内部的每个动作经 `SubActions`(`core/tool/SubActions.kt`)回到同一个入口,和单步工具同一条执行、记录路径。
- **工具声明**:每个工具一个类,实现 `AgentTool`(`core/tool/AgentTool.kt`),声明自己的动词、风险(Read / Write / External)、所属能力、能否进一次多步、能否进经验路线、并发方式等;代码按这些属性办事,不写死工具名。`ToolRegistry` 按任务筛出可用工具,一个任务内工具定义冻结不变。
- **工具分两层,用到才展开**(`core/tool/ToolGroups.kt`、`ToolDispatcher.offeredNow`):一直都在的是通用工具(问用户、没办成就结束、等待、知识与技能、记忆、定时、子任务……)和不收的能力(MCP);手机、网页、沙箱各是一组(`Capability.folds`),开局只给入口工具(`AgentTool.entry`),用 `prompts/entries/<工具名>.md` 的短说明:`phone_launch_app`、`web_navigate`、`run_command` 调用后展开它那一组(`Entry.Opens`),`phone_list_apps`、`web_read_page`(读一个网址的正文)单用就够,不展开(`Entry.Alone`)。接管、报障碍、回看画面、一次多步只在有屏的组展开后才给(`AgentTool.withScreens`,算作 `screens` 一组)。展开后下一次请求起给整组全文,入口换回全文,能力那一段说明(`capabilities/<能力>.md`)也拼进系统提示词,本任务里一直留着;结果里的 `moreTools` 说一句。模型凭记忆调了收着的工具照常执行,同样展开它那一组(参数不对也展开,下一次看得到全文再改)。顺序固定:一直都在的 → `screens` → 手机 → 网页 → 沙箱,展开一组只改从它那一组起的部分,请求前缀因展开最多失效一次。开局预先展开:上一轮真的用过的组(`Turn.groups`,`AgentLoop.usedGroups` 记下、会话文件里存着;单用就够的入口不算用过),和能力按这次的话认定一定用得上的(带着工作区里的文件时的沙箱,`Capability.preload`)。只读的任务里跑命令不给,沙箱一组没有入口,就一开始展开。子任务、定时触发和别的来源都一样。运行记录里展开记 `tools_opened`、预先展开记 `tools_preloaded`;`PromptVersion` 按每组都展开的样子算,同一版提示词不因这次展开了哪几组而不同。
- **审批**:`ApprovalPolicy`(`core/action/Approval.kt`)只问一种动作:花钱的那一步(`Risk.payment`:不可逆判定认出的付款、下单,或屏上有带币种的应付金额时命中判定的对外、不可逆动作,见 `ClassifiedRisk`)。别的一律直接做:发送、发帖、提交、删除、联系别人、改账号或安全设置、往密码框里打字、看图找到却看不出是什么的点、读不出目标的点击,都不问。花钱的一步只有手机上发起、用户在场时问(`Ask`);渠道、定时、MCP 发起的运行、子任务和无人在场的运行问不到人,一律拒绝(`approval_unavailable`)。同意只有一种:只放行紧接着的那一个匹配操作(`ConfirmationGate`:同一 App、同类动作、对象在屏上、申报的金额在屏上,3 步内、中间没做别的写操作)。付款本身(判定是 pay)不经同意,交给用户亲手付(`hand_to_user`,reason=payment)。审批结论都记进运行记录(`RunEvent.Approval`,对外的、花钱的和没放行的),核查据此知道任务做过对外的事。没有审批档位,也没有任务内、长期授权。
- **询问 / 自主**:全局一个开关,在设置 › 开发者选项最前面的「做事方式」一组(「自主模式」,测试标签 `dev-autonomous`),默认关即询问;值存在 `ApprovalStore` 的 JSON 里(`autonomous`)。`ApprovalControl`(`settings/ApprovalControl.kt`)在每个任务开工时给一份 `RunApproval`(自主与否取全局的),`TaskRun` 据此装:询问时 `confirmations = ConfirmationGate()`、`autonomous = false`,花钱的一步被拦成 `confirmation_required`,下单一类由模型调 `request_confirmation`(必须写出屏上的金额,写不出金额的申请直接退回、不交给用户),用户在对话页的卡片、悬浮胶囊的卡片或通知上回答(只同意这一次 / 不同意),回答经 `TaskService` → `TaskRun.confirm` → `RunInbox` 交给循环;自主时 `confirmations = null`、`autonomous = true`、不提供 `request_confirmation`,判定照跑(对外动作的间隔照样认)但连付款也不拦。换开关(`ApprovalControl.setAutonomous`)存下来管之后的任务,正在跑的任务都跟着换:开工时是询问的任务中途换成自主,下一步起不再拦(已经弹出的确认照常等回答);开工时是自主的任务中途换成询问,留给下一个任务(工具和提示词一个任务内不变)。悬浮胶囊里发起、接着聊的任务、子任务、定时触发和 MCP 发起的运行都按这个开关;子任务本来就只读,询问时它过关卡、花钱的一步一律拒绝。定时任务这类无人在场的运行在询问模式下遇到花钱的一步不暂停等人:执行入口直接拒绝(`approval_unavailable`),模型照实说没做成哪一步,别的照常做完,结果照常通知。自主模式下 `ConfirmationGate` 不存在,`ConfirmDecision.judge` 里自主的提前返回排在「不对 Mika 自己动手」(`ownApp`)检查之前,所以这层保护在自主模式下不生效;询问时 Mika 不操作自己。
- **改名后的旧工具名**:`LegacyNames`(`core/tool/LegacyNames.kt`)把旧名对应到现在的工具,只用来读旧的路线、运行记录和个人技能;模型调用旧名时只提示新名,不替它执行。
## 5. 观察、动作、地方
- **观察**是核心消费的唯一单位:`Observation`(`core/observation/Observation.kt`)带种类 `ObsKind`(Read / Output / Reply / UserSaid / Attachment / Fact)、出处(能力 id)、地方、标题、文字、画面、可信度、步数、时间和编号。证据账本、来源、核查、运行记录都读它。
- **不可信内容**(网页、子任务结果等)在构建观察时一次性包进 `…`(`core/observation/Untrusted.kt`)。
- **地方** `Place`(同一文件):App、Site、Page、Command、File。知识按地方补给模型(§11)。
- **界面状态**(`core/uistate/`):控件树、元素编号与过期检测、等界面稳定、动作效果(`ActionEffect`,每个改变界面的动作报回实际效果、输入读回核对;手机上点、手势看不出变化而这一屏的树读不出那一处——树不能用,或缺了一块而点不在任何元素上——种类照旧是 `no_effect`,另标 `treeBlind`,提示改说「看不出变化不等于没点上」,不说多半没点上;按种类做的事(连着没效果的提醒、多步提前停、统计)不变;动作之后的界面里新出现「到上限了」的短文字——次数已用完、已达上限、明天再来、操作过于频繁、limit reached 一类,提示条、弹窗、页面弹框的话,动作前就在屏上的不算(`LimitNotice`)——结果里写 `limitReached` 和停下的提示,一次多步停在这一步)。手机和网页共用这一套。分发器每次调用前后经 `SurfaceLayer` 让它做界面上的准备与收尾(动作前看一眼、比出效果、附上动作之后的界面),工具引擎本身不认识它(见 §1)。执行入口的不可逆判定与审批、回放按读屏判定时直接用它(`ActionTarget`、按描述找目标)。
- **可视化**(`core/visual/`):画面帧编号、`view_past_screens` 回看、画面差异。画面只是让用户看到 Mika 在做什么、以及文字读不出时的补充观察;办事靠的是界面状态。
## 6. 能力模块
能力由 `CapabilityProvider` 按 `RunSpec` 装配(`core/assembly/RunAssembler.kt`、`TaskTools.kt`),每个 Run 开局装一次:
- 按 `spec.capabilities` 选能力;无人在场的运行去掉追问、确认、接管、建定时、改画像这几个工具;只读运行只留只读工具(外加网页打开地址)。
- 通用工具(`core/general/`)一直都在:`ask_user`、`hand_to_user`、`handle_obstacle`、`end_not_done`、`wait`、`view_past_screens` 等。条件满足才提供的:`request_confirmation`(花钱前请用户同意;有确认关卡才有:询问模式、有人在场时)、记忆工具(「使用记忆」打开才有)、知识与技能查阅(有知识目录才有,`LookupTools`)、`schedule_task`、`spawn_agent`。
### phone:手机 App
- **虚拟屏**:任务在一块单独的虚拟屏上操作 App,用户照常用手机。虚拟屏由内置手机助手建(`app/cap/phone/display/AgentDisplay.kt`、`app/cap/phone/shell/ShellService.kt`),尺寸与主屏相同;安卓 14 起有自己的焦点,不影响主屏输入(安卓 13 上主屏焦点可能短暂丢失,设置里会提示)。第一次需要打开 App、链接,或用到系统命令、事件监听时才连接和建屏;只用网页、沙箱的任务不碰它。目标 App 在虚拟屏上静音,接管时恢复声音。静音靠把该 App 的 `PLAY_AUDIO` 设为 ignore;静音前的原模式记在内置助手的记录文件里(`app/cap/phone/shell/AudioMuteLedger.kt`),助手启动时先恢复上次没来得及恢复的。恢复规则:原来明确是 allow/ignore/deny 等的照原样写回,没设置过的写 allow、不写 default(安卓只认 allow 才放声音),只动记录里的包。
- **内置手机助手**:一个以 shell 身份运行的辅助进程,经手机自己的「无线调试」启动(`app/cap/phone/setup/BuiltinShellManager.kt`、`app/cap/phone/shell/HelperProcess.kt`;ADB 协议是自己实现的,在 `core/adb/`)。配对一次即可:配对码可由无障碍服务从设置页自动读出,也可从通知或设置向导里输入。填码通知(`app/cap/phone/setup/PairingNotifier.kt`,由 `PairingCoordinator` 按助手状态和找配对窗口的进度发)不加屏幕录制、悬浮窗权限,只靠通知本身:「配对」频道是高优先级、带声音和振动,能弹在系统配对窗口上方;频道的重要程度建好后 App 改不了,`PairingChannel.plan` 看已有频道——没有就新建,旧版本建得不够高、用户又没改过(`hasUserSetImportance`)就换新 id 重建,用户自己关掉或调成不弹出的不改回去,由 `SystemStatus.pairingChannel` 交给引导页(`SetupView.notifyIssue`):没允许通知或频道被关时,配对一步的主按钮先是去允许通知 / 打开这个频道的设置页,调成不弹出只提醒一句并给入口。找到配对窗口就发「输入配对码」(无障碍在读码时才晚 5 秒),动作「填配对码」带 RemoteInput(不要求解锁、不给生成的回复);没人填就隔 8、20、40 秒再弹,最多三次,每次都重新响铃弹出,已换成别的内容就不再弹。填的码先在本地规范(空格、各种横线、全角数字)并校验 6 位,不对当场在同一条通知里提示、不去试;对的马上换成「正在配对…」,成功变「配对成功」后自己消失;码不对、过期或窗口关了,在同一条通知里说去拿新码再填,输入框留着。无障碍没开时引导页用三行编号小图示讲这三步,页面里直接填码的兜底照旧。设置向导(`app/ui/PhoneSetupScreen.kt`)的状态机是纯函数 `PhoneSetupFlow`(`core/shell/PhoneSetupFlow.kt`),读到的开关只用来自动打勾和给提示,不拦死:开发者选项、USB 调试、无障碍、无线调试四步读到开着就自动打勾、往下走;读到关着就停在那一步,但读开关的三步都有「我已经打开了,下一步」(记进 `SetupInputs.confirmed`),无障碍可以跳过(推荐:配对后 Mika 自己打开,也不会被「受限制的设置」拦住),配对一步除了通知、自动读码还能在页面里直接填,所以哪一步都不会卡死。做完的步骤可以点回去重做(`SetupInputs.revisit`,回去的那一步读到从关变开就自动回到原处)。成功只看助手是否在运行,不管前面读到什么。配对或连接失败(配对窗口关掉、连不上、原因不明)时,按读到的开发者选项和无线调试指出最可能出问题的那一步(`SetupView.suspect`:读到关着,或这台手机读不到),给回到那一步的按钮;用户确认开了无线调试、助手却读到关着不去连时也这样,不一直转圈。断开后自动重试(指数退避)。找无线调试的连接端口(`core/adb/AdbConnectPort.kt`)按顺序试,哪个连上就停:上次连上的端口(存在 App 私有设置里,失效时被拒得很快)、系统属性 `service.adb.tls.port`(读得到才用)、mDNS 查 `_adb-tls-connect._tcp` 得到的每一个本机端口(`NsdAdbServiceFinder`)、扫本机端口(`core/adb/LocalPortScan.kt`)、用户在引导页填的端口。扫描是前面几样都不行时的兜底(有的网络拦着组播、有的系统不让 App 读属性):无线调试的 adbd 监听系统随手分配的端口,所以只扫临时端口范围(读得到 `/proc/sys/net/ipv4/ip_local_port_range` 就用它,否则 30000–65535),只扫本机自己的地址——先 127.0.0.1,一个像 adbd 的都没有再扫 Wi-Fi 上的 IPv4 地址,不碰别的设备;`NioPortScanner` 用 java.nio 非阻塞连接,同时最多 200 个在连、每个最多等 150 毫秒,跳过前面试过的端口和 Mika 自己开着的监听(`OwnListeningPorts`,如登录回调);开着的端口发 CNXN、回 STLS 才算像 adbd(自己连上自己、别的服务都不会回 STLS),再照常用已配对的钥匙做完 TLS 握手,第一个连上的胜出、记作缓存;扫描和核对合计不超过 10 秒,在 IO 线程上跑,扫的时候 `BuiltinShellManager.scanningPorts` 为真,引导页显示「正在本机查找无线调试端口…」。连不上时引导页连接一步有「填无线调试的端口」(数字键盘,1–65535,整段粘了「IP 地址和端口」也取冒号后面),填了就带着它重试一次(`BuiltinShellManager.connectWithPort`,只用这一次;这次不再扫,连上了记作缓存)。引导页除了成功,哪个阶段(等用户、正在连、失败)底部都有「稍后再说」,返回也总能离开,不把用户困在这一页。几项能力「替我打开无障碍」「不受省电限制」「重启后自动恢复」「自动打开无线调试」默认打开:都要靠已配对的连接才能生效,所以助手每次变成运行中(配对完、掉线重起、App 启动时已配对、开机自动恢复连上)由 `BuiltinShellRecovery.start`(`app/cap/phone/setup/BuiltinShellRecovery.kt`)看着状态,经 `RecoveryPermissions.applyPending`(`core/shell/RecoveryPermissions.kt`)补上用户没亲手关掉、还没生效的几项,不弹框,失败只记下给设置页说一句。记录(`app/settings/RecoverySettings.kt`)每项分两样:「已生效」和「用户亲手关掉」;旧版本只有前一样,没关掉过的一律按默认打开。解除配对收回已生效的几项,不动用户的选择,再配对时照样自动打开,亲手关掉的仍关着;开机自动恢复看「重启后自动恢复」是否已生效。无障碍只加 Mika 自己的服务,别的服务不动。Mika 的无障碍服务可能因为两种原因停掉:进程被系统杀掉后,系统把它当成崩溃了,设置里还开着却不再绑定;被强行停止时,系统直接把它从无障碍列表里拿掉。`BuiltinShellRecovery.start` 同时看着系统列表里有没有 Mika 的服务、服务实例连没连上和助手的状态(都有回调,不轮询);服务没在运行超过 3 秒、又有办法写系统设置时(App 自己有修改系统设置的授权就直接写,不用等助手,无线调试关着也能修;没有授权才等助手在运行),由 `AccessibilityRepair`(`core/shell/AccessibilityRepair.kt`)经 `RecoveryPermissions.restartAccessibility` 把它打开:列表里没有就直接加上,有却没绑定就去掉、停一下再加回去,系统就会重新绑定;等服务连上才算修好。不分是谁关的,一律自动打开,只有在 Mika 里关掉了「替我打开无障碍」才不管;每次卡住最多试两次,还不行就停下,留给界面上手动处理的提示。修好了只在设置页那一项下面说一句时间。任务接上手机时照常等无障碍服务 5 秒;正要修或正在修(`AccessibilityRepair.phase`)就接着等到修完(最长约半分钟),放弃了才报手机不可用。只在安卓 13 及以上启用。助手起来后就不再需要无线调试,但重新启动它(重启手机、Mika 更新、助手没了)要靠无线调试,而安卓原版在 Wi-Fi 断开时、重启后都会把它关掉。「自动打开无线调试」:看门狗要起助手、无线调试却关着,且连着 Wi-Fi 时,由 `WirelessDebuggingAutoOn`(`core/shell/WirelessDebuggingAutoOn.kt`)把 `adb_wifi_enabled` 写成 1 再照常连接;Wi-Fi 连上时(网络回调)助手不在也会这样做。只开不关,不碰 USB 调试和开发者选项。只由 App 自己写(要有修改系统设置的授权,和「重启后自动恢复」共用,两项都关掉才收回;要起助手时没有别的连接可用),没授权或用户关掉这一项就不写,界面照旧给手动打开的办法。原版在不允许的 Wi-Fi 上会马上把开关拨回 0 并弹框问用户:写完 3 秒内读到拨回就停下,状态报「这个 Wi-Fi 还没允许无线调试」(`BuiltinShellFailure.WifiNotAllowed`),请用户亲手打开一次并勾选「始终允许」,直到换了 Wi-Fi、用户亲手打开、点了重试或助手起来了才再写,不会反复弹框;写入被拒、或连着写了 3 次助手都没起来也停下;两次写之间按 10 秒起翻倍的间隔拉开。
- **用电脑启动内置助手(USB)**:无线调试配对不成功、或由电脑上的 Agent 来设置时,电脑经 USB 调试跑 `adb shell sh /sdcard/Android/data/com.wangzan101.mika/files/usb-start.sh`,起的是同一个助手(同样的 `app_process` 命令和参数),交接也走同一个 `HelperHub.offer`,shell 身份、APK、Binder 所在进程、口令和签名指纹的核对一样不少。脚本由 `UsbStartScript`(`app/cap/phone/setup/UsbStartScript.kt`)写在 `Context.getExternalFilesDir(null)` 里(App 自己写不用权限,shell 读得到,别的 App 读不到),文字是纯函数 `HelperHandoff.usbScript` / `usbStub`。跟着 `HelperHub.shell` 重写:助手不在时(App 启动、有人要用助手却没有、打开引导页)写带一次性口令的脚本,口令是 `HandoffNonces.usbNonce`——和启动口令、续接口令放在一起,任何一枚被用掉几枚都作废;按墙上时钟 24 小时过期(要跨过重启),还剩一半以上时效就沿用,免得电脑正在跑时被换掉;助手连着时换成不带口令的说明(「已经在运行」,进程不在就请用户先打开 Mika),磁盘上不留能用的口令。脚本只在 `id -u` 是 2000 时起;助手用 setsid 另起会话、放进后台子 shell(子 shell 退出后归 init),口令经内建 echo 走管道进标准输入,输出丢掉,所以 adb 会话结束、拔线后 adbd 杀掉它那一组进程也碰不到助手;脚本等 2 秒再用 `pidof` 看助手进程还在不在,说一句「正在启动,打开 Mika 确认」或「没起来,先打开 Mika 再跑一次」。没配对时收下助手,引擎记下「是电脑起的」(`BuiltinShellPlatform.usbStarted`),状态照常是运行中,后续(补上那几项能力、打开无障碍)和配对后一样:`commandRunner` 助手在时经助手的 `runCommand` 跑命令,不在才连已配对的 adbd。这样起的助手重启后就没了;没配对过时 Mika 自己起不来,新进程先等一会儿(还活着的助手会自己来交接),等不到就报 `BuiltinShellFailure.UsbHelperStopped`(「手机助手已停止」:再在电脑上跑一次命令,或配对无线调试让它自动恢复),开机自动恢复也不去动无线调试;解除配对把这个记录一并清掉。引导页清单下面有一条收起的「用电脑启动(USB 数据线)」,展开是命令、复制按钮和一句说明。
- **读与操作**:无障碍服务(`app/a11y/MikaAccessibilityService.kt` → `app/cap/phone/a11y/ScreenAccessibility.kt`)按屏读控件树、在指定屏上做手势。每次读屏先按控件树判它够不够用(`core/uistate/TreeQuality.kt`):有名字的元素够多、控件大多有名字、App 窗口里没有一大块一个字也没有的区域,才算明显够用,不识别文字;拿不准时用随 APK 自带的文字识别(`app/cap/phone/a11y/PhoneOcr.kt`)读一次,树里没有的字补出三行以上就算缺了一块(partial);树缺了一块或读不到(unusable)时一定识别文字(看不到图的主模型、回答来源和核查、`phone_collect` 都靠它);防截屏页面不识别。补上的字(`core/uistate/VisibleText.kt`)先去掉认错的行(置信度低、全是符号、以中文为主的屏上零碎的西文数字串),每行带 0–999 的位置和点击中心;树不够用时读屏结果写上 `treeQuality` 和一句提示:按屏上文字的中心点,没有字的控件(图标、图片、输入框)用看图定位,要往框里打字就给输入它的点或在 element 里说是哪个框,看整屏描述只在看不懂是什么页面时用(它慢好几倍,不拿来找一个控件)。主模型能看图、开了「先读元素,必要时看图」时还附上画面。识别文字前先等画面停稳(自绘界面几乎不发无障碍事件,事件安静了画面可能还在过渡),认的过程中换了整页就按新的一页再认一次,局部在动(动图头像、光标)照用(`app/cap/phone/a11y/StillFrameRead.kt`);树在读的过程中变了也照样识别。树不够用而一行字也没读到时,读屏结果写 `ocr`(empty、illegible、timeout、unavailable、failed),提示改为用看图定位找输入框和按钮。输入一般直接写进输入框,网页类编辑区用剪贴板粘贴,用完把用户原来的剪贴板还回去。树里没有拿着焦点的输入框时(App 自己画界面),先全选再经剪贴板粘贴,Ctrl+A / Ctrl+V 由内置助手只发到 Mika 的屏(`injectCtrlKey`),之后比打字前后屏上识别到的文字核对(`core/uistate/UnseenField.kt`:多出现一次为一致,前后完全没变为没打进去,其余为核实不了;弹着输入法时键盘上的字不算)。不要求弹着输入法:按键发到这块屏拿着焦点的窗口,框不经输入法也收得到;没弹输入法而前后完全没变,多半是焦点不在框上,报 `no_focus` 请模型先点输入框,不再盲目重粘。屏上什么都没拿着输入焦点(各窗口里找不到输入焦点)、也没弹输入法时根本不粘,直接报 `no_focus`:没有框接得住,粘了界面别处一动反而会被当成「可能打进去了」(清空同理)。清空同样用全选加删除键,没弹输入法而界面没变时同样报 `no_focus`。输入、清空也接受一个点(坐标,或看图找到的 `@vN`,和点击一样先核对画面没变):先点那一点、等界面安静,点出了树里看得到的输入框就照常对着它打,否则按上面的办法粘贴,粘贴前的屏上文字在点完之后读;粘贴后前后完全没变报 `point_not_typed`(那一点多半不是输入框),不自动重试,核实不了不按回车(`ScreenPort.typeAt` / `clearAt`、`AndroidScreen.typeAt`)。树读不出输入框的屏上不必先点再打:点一下只让框拿到焦点时树和画面都不动,打字的核对才是点中的凭据。往一点上打字时那一下点击也过不可逆判定(点到的是发送一类的按钮按点击判)。按描述指输入框(`element`)而树里没有对得上的、这一屏的树又缺了一块或不能用时,在执行入口换编号那一步看图找这个框(`Interfaces.bySight`,配了看图模型时;和 `find_visually` 同一套核对,没佐证的标出来),参数换成找到的 `@vN`,之后和模型自己传 `@vN` 一样核对画面、判不可逆、粘贴后核对;结果写 `foundBySight` 和看到的字,省掉先找点再打字的那一轮。
- **手势的节奏**(`core/cap/phone/GesturePacing.kt`,随机取值、弧线、缓动和网页鼠标的 `InputPacing` 共用 `core/uistate/Pace.kt`):每次落指前停 30–120 毫秒;点按按住 50–120 毫秒,双击每下 40–90、两下之间 80–140(在系统认双击的时限里);点元素时落点在中心附近偏一点(不超过宽高的五分之一、至多 12 像素,窄于 24 像素的方向不偏,总在元素里),按坐标点至多偏 3 像素。滑动(`phone_swipe`、`phone_scroll` 退回的手势、单指 `phone_gesture`)起终点不变,两点之间走一小段弧线(偏离不超过距离的 6%、至多 30 像素,多点路径照画好的走),时长在给定值上下浮动 8%,起步慢:短于 800 毫秒的快滑松手时的速度和以前匀速滑动相当(甩出去的远近不变),不短于 800 毫秒的慢拖收尾停稳再抬手、不甩;起终点几乎重合(原地按住)只加长不缩短。快慢要每个点有自己的时刻,无障碍手势做不到(系统按路径匀速走),所以有节奏的一笔由内置助手按时刻逐点注入(`injectStroke`,只发到 Mika 的屏),助手注不了时退回无障碍手势沿同一条弧线走;点按仍走无障碍手势,不行再由助手注入。双指手势只让时长浮动。控件自己的点击、滚动动作(`ACTION_CLICK`、`ACTION_SCROLL_*`)和输入不受影响。`GesturePacing.Instant` 和以前完全一样(测试用)。
- **操作节奏**(设置 › 干活时 › 操作节奏,`core/cap/phone/OperatingPace.kt`,存在 `FeatureFlags.operatingPace`):手势三种都像人,另外三样按它来——「快」(`FAST`:动作之间不另停、打字一次写入、不设屏数上限,即以前的做法)、「标准」(`STANDARD`,默认)、「像人」(`HUMAN`:所有 App 都按慢的那一套停留和打字)。每个任务开头读一次(`TaskRun`),评测和真机验收经 `EvalHooks.operatingPace` 覆盖,缺省用「快」(`-e evalPace` / `-e livePace` 换成 `standard`、`human`),不改设置里的;核心的 `toolDispatcher` 默认 `AppPacing.Off`,`AndroidScreen` 默认 `TypingPacing.Instant`,测试照旧跑得快。
- **打字**(`core/cap/phone/TypingPacing.kt` 纯逻辑):`AndroidScreen.typeText` 找到输入框后先按节奏一段一段写进越来越长的前缀(`ACTION_SET_TEXT`,对任何原生输入框都管用,中日韩文字照样),最后一段全文照旧交给 `typeAndVerify` 整段写入、读回核对、重试。拉丁字母每个字 60–170 毫秒,中日韩文字每个字 160–380 毫秒、连着的 1–3 个一起上屏(像输入法选词),标点、空格之后偶尔多停 0.3–0.9 秒;字多时几个字一段、至多 40 段,总时长至多 6 秒(限制操作快慢的 App 和「像人」:90–230 / 220–520 毫秒,停顿 0.5–1.4 秒,至多 60 段、12 秒)。一个字不拆开(代理对、附加符号、变体选择符、零宽连接的表情序列)。写第一段后读回:框不接受半截字(读回对不上、读不出)或写不进就停下,照旧整段写入。密码框、要粘贴才收得到的网页框(`TypingPacing.forField`)和树里看不到的框(粘贴)照旧一次写入。
- **动作之间的停留**(`core/cap/phone/AppPacing.kt` 纯逻辑,`AppDwell.kt` 接到手机能力上):每个改动屏幕的动作都经唯一的执行入口 `ActionExecutor`,它在执行前问这块屏的能力(`Capability.beforeWrite`,手机能力按 `AppDwell` 停),执行后告诉它结果(`afterWrite`);单步、`do_steps` 一批里的每一步、回放都一样,组合动作(`phone_run` 一类)本身不停、里面的每一步各自停;网页仍按它自己的一页一页的停留(`PageDwell`),不经这里。上一个动作的结果里效果是 `done` 或 `place_changed` 算换到了新的一屏,否则是同一屏:标准时同一屏停 0.5–1.2 秒、新的一屏 0.9–2 秒,5% 的机会多停 0.6–1.5 秒,一次至多 3.5 秒;限制操作快慢的 App(App 知识 `pace: careful`,按包名 `GuideCatalog.appPace` 查)和「像人」时同一屏 0.9–2.2 秒、新的一屏 2.5–5 秒,另按模型最近读到的那一屏的字数每字加 15 毫秒读的时间(至多 8 秒),10% 的机会多停 2–5 秒,一次至多 15 秒。停留从上一个动作做完算起,模型想、读屏花掉的时间都算在内,只补不够的部分;停了多久和对外动作的间隔一起记成这次调用的等待(运行记录 `tool_result.waitMs`),不告诉模型;停留中用户插了话就不做这个动作,报 `skipped_for_new_message`。慢的那一套的新的一屏上停得够久、接下来的动作不按屏上的位置做(返回、按键、按方向滚)时,停留里按 `IdleScroll` 的安排在占了大半屏的可滚动内容正中慢慢划一小段(`AndroidScreen.idleScroll`,慢拖、停稳再抬手;弹着输入法、输入框拿着焦点、上面盖着别的应用窗口、没有够大的可滚动内容时不划),划了几下只进运行记录(`idleScrolls`),模型手里那一屏标成过时(`ScreenSession.movedByMika`)。
- **屏数上限**:限制操作快慢的 App 一个任务里换到新的一屏至多 24 次(`AppPacing.screenCap`,往下滚读同一页不算),到了之后在 App 里换屏的动作(点、滑、返回、按键、手势)不做,报和网页同一个 `page_limit`(`core/uistate/DwellStop.kt`,说明里让模型先拿已读到的回答、要接着看就用 `ask_user` 问);打字、清空、滚动、拨滚轮、打开别的 App 照常。用户答过话(`Capability.onUserAnswered`)后重新计数;对外动作本身不拦,在这个 App 里做成过一次之后本任务不再设上限(经 `Capability.onOutwardEnd`)。普通 App 不设上限。
- **工具**(`core/cap/phone/`、`core/uistate/tools/`):
- 基本:`phone_observe`、`phone_list_elements`、`phone_tap`、`phone_type_text`、`phone_clear_text`、`phone_scroll`、`phone_swipe`、`phone_back`、`phone_press_key`、`phone_launch_app`、`phone_share_text`、`phone_open_link`、`phone_list_apps`、`phone_screenshot`、`phone_diagnose`。默认的纵向 `phone_scroll` 优先用可滚动控件,找不到才退回手势。
- 查询与复合:`phone_query`(在完整控件树里按文字、资源 ID、控件类型查找,只读文字)、`phone_run`(一串有序命令,每步在当前页面重新定位,可按文字条件决定是否继续;目标缺失、无法区分、切到别的 App 时停下交回模型)、`phone_collect`(逐屏读长列表或聊天并按重叠合并)、`phone_find`(滚动找目标)、`phone_extract`(可见文字、列表行、表单字段;密码不返回值)、`phone_dialog`(读弹窗,只有给出唯一按钮文字时才点)、`phone_wait`、`phone_event`(等待或收集窗口、内容、通知事件)、`phone_gesture`(单指路径或双指)、`phone_set_wheel`(把滚轮转到一个值,见下)。
- 系统动作:`phone_set_alarm`、`phone_set_timer`、`phone_show_alarms`、`phone_add_calendar_event`(见下)。
- 其他:`phone_system_command`(经内置助手执行一条系统命令,与 Linux 沙箱分开;10 秒超时;命令在做点、滑、打字、按键、打开 App 这些专门工具做的事,或连着失败两次时,结果里提醒换回专门工具;本任务提醒过之后,再做那几类事的命令不执行(`use_phone_tool`,只回该用哪个工具),用 shell 分享文字(`am start` 带 SEND、不带文件)一律不执行、指向 `phone_share_text`,查状态、读设置的命令不受影响,`core/cap/phone/CommandSteer.kt`)、看图类工具见下。
- **分享文字到 App**:`phone_share_text` 把一段文字按安卓的文字分享(ACTION_SEND text/plain、EXTRA_TEXT,只交给指定的包)启动到 Mika 的屏(内置助手 `startShare`,和打开 App 同样查用户是不是正在主屏用它、别的任务占没占着),之后和 `phone_launch_app` 一样等它到前台、认系统问打开哪一个(装了两份;这个 App 有几个收分享的页面时系统问用哪一个,最上面换成系统包的窗口也算),记下任务。它只打开分享页、不发出:收件人和发送键由模型在那一页上点,发送照旧过不可逆操作的确认。这个 App 不收文字分享时报 `cannot_share`。读不到、打不进字的 App 里发消息、记笔记,先走它(`core/cap/phone/AppTools.kt`、`AndroidScreen.shareText`)。
- **系统动作(闹钟、计时器、日程)**:`phone_set_alarm`(时、分、标签、每周重复的日子、振动)、`phone_set_timer`(秒数、标签)、`phone_show_alarms`、`phone_add_calendar_event`(标题、起止、全天、地点、备注)按安卓的标准接口交给手机上的时钟、日历 App(`AlarmClock.ACTION_SET_ALARM` / `ACTION_SET_TIMER` 带 `EXTRA_SKIP_UI`、`ACTION_SHOW_ALARMS`、日程的 `ACTION_INSERT`,类型直接写明 `vnd.android.cursor.dir/event`;不要日历权限,设闹钟要的 `SET_ALARM` 是普通权限),不用一格一格操作它们的界面(`core/cap/phone/SystemActionTools.kt`,intent 由 `app/cap/phone/shell/SystemIntents.kt` 先写成与安卓无关的 Spec 再换成 Intent)。先在 Mika 这边按包管理器查谁接(清单里的 `` 列了这几种):没有 App 接时报 `not_supported`,模型退回去操作时钟、日历的界面;有几个时用户设过默认的用默认的,否则先挑系统自带的,直接指定到那个页面,屏上不留「用哪一个」的选择框。之后交给内置助手启动到 Mika 的屏(`startSystemAction`,只放行这几种 intent,和打开链接一样查接手的 App 是不是正在主屏用着、别的任务占没占着;动作前后照常盯着主屏,开到主屏上的挪回来)。接手的页面要求 shell 没有的权限(时钟的设闹钟页要求 `SET_ALARM`)时,助手改为发出 Mika 做好的 PendingIntent:以 Mika 的身份启动、权限按 Mika 查,屏和后台启动按发出的助手查,仍落在 Mika 的屏上(安卓 14 起发出方明说允许后台启动)。闹钟、计时器本该悄悄设好:设闹钟前后读系统报的下一个闹钟(`AlarmManager.getNextAlarmClock`,最多等 3 秒),结果的 `check` 为 verified(下一个就是它)、earlier_alarm_first(更早的闹钟挡在前面,看不到它)、not_seen、unknown,后两种和挡住的那种提示用户要确定时 `phone_show_alarms` 看一次;App 却开了自己的界面时报 `screenOpened`,请模型在那一页上确认。闹钟列表、新日程等那个 App 到最上面、记下任务;新日程不替用户保存,保存键由模型在那一页上点。设闹钟、计时器是收着的手机一组里单用就够的入口(`Entry.Alone`,不展开整组),新日程是展开整组的入口,闹钟列表在组里;风险同打开 App(只改手机上的东西、不花钱,不要确认)。
- **滚轮选择器**:时间、日期、地区这类一格一格转的滚轮,`phone_set_wheel`(滚轮或它此刻显示的那一项的编号、要显示的值)一次调用在端口里转到位,不用模型看一眼滑一格(`core/cap/phone/SetWheel.kt`、`app/cap/phone/a11y/WheelDriver.kt`)。给的编号可以是滚轮,也可以是它显示的某一项(自己画的滚轮常是选中的那一项):这时滚轮是往上找到的、和这一项横向对齐的那一列(不少于三项、间距差不多,`WheelMath.column`),同一块里并排的别的滚轮(时旁边的分)不读、不拖;往上找到的能滚的容器要横向罩着这一项的正中才拿来无障碍滚(整页的滚动区不算)。按控件能做什么依次试:滚轮里有输入框(原版的数字滚轮有)就把值写进去、让它失去焦点确认(因此弹出的输入法收起来),读回来;能无障碍「往前 / 往后滚」(原版数字滚轮一下一格)就读出当前值,按 `WheelSteps` 一批至多 5 下地滚、每批读回重算,数字按数差定方向、看得出循环(…58 59 00 01…)就走近的一边,第一次读回时校正「往前」是变大还是变小,值不动了:看不出循环是这一边到头、目标不在范围里(`at_end`),看得出循环(没有头)是这一下没起作用、交给拖动(`not_moving`),一下转过不止一格、来回越过目标几次就交给拖动,至多 80 下;文字列表目标在眼前按位置差,不在就往一边扫、到头反过来;都不行(App 自己画的滚轮)就读这一列看得到的几项(无障碍读不到就认屏上的字)估一格多高和当前值,手指落在这一列横向正中、往上拖从靠下的一项起(往下拖从靠上的起,可以拖出这一列),按格数慢拖(不短于 1 秒,按节奏走时末尾停稳再抬手、不甩,免得转过头),读回后按实际转过的格数校准一格多高再补拖,至多 5 轮(`WheelDrags`);拖了值不动不算到头,只有看不出循环、往要去的那边已经没有项才是 `at_end`,否则换快划(0.4 秒)再试、只差一格的拖远一点,连着 3 次不动报 `not_moving`,提示模型自己用 phone_swipe 在这一列上划。认值、方向、循环、格数、一格多高这些纯算法在 `core/cap/phone/WheelMath.kt`(`"09"` 就是 `"9"`、`"40分"` 就是 `"40"`;选中项的无障碍名字常带状态词,`"已选中,21"`、`"Selected, 21"` 就是 `"21"`,整段只有一个数时取它;文字值也按标点切开的段比;系统单独报的 stateDescription 被拼进名字时先拿掉;只有一项标着 isSelected 时它就是选中的,否则是自己的上下范围盖住中线的那一项,都没盖住(有的滚轮只报上下两格)就按上下紧挨着的两个数推出中间那一格(39、41 中间是 40,59、01 中间是 00,`WheelMath.between`),推不出才退回离中线最近的;每次滚动、拖动、输入之后都等停稳(隔 0.35 秒连读两次值相同)再读,读到目标后停稳了再核对一次,不对就接着转,停稳的值对上才报到了;报给模型的 `value`、`from` 只留值那一段)。结果给 `value`(此刻显示的)、`from`、`attempts`、`method`(text、actions、drag),没转到时报 `wheel_at_end`、`wheel_unreadable`、`wheel_not_moving`、`wheel_not_reached` 并照实给此刻显示的值。读屏树里像滚轮的节点标 `wheel: true`(原版数字滚轮控件,或能滚、里面三到九项短值在正中竖着等距排开的,宁可漏认,`WheelMath.looksLikeWheel`、`ScreenReader.isWheel`)。能放进一次多步,几个滚轮(时、分)一批设好。
- **装了两份的 App**:同一 App 装在两份用户里(分身、工作资料)时,有的系统打开时先问打开哪一个。`phone_launch_app` 打开后最上面换成了系统包的窗口、窗口里可点的选项提到这个 App 的名字,就算打开了,结果带 `chooser`(选项和哪个是另一份)和提示:按用户说的选,没说就选本来那份,别再打开一次;那个窗口在前台不算被切走,选完 App 到了前台再记下它的任务(`core/uistate/LaunchChooser.kt`、`AndroidScreen.openApp`)。接上内置助手后 `phone_list_apps` 给也装在别的用户里的 App 标 `alsoIn`(内置助手读 `pm list users` 和各用户的包)。
- **开到用户主屏上的 App**:Mika 屏上的 App 不从自己在这块屏上的页面、而是从后台(服务、跳转、系统的选择器、跨用户的转交)打开另一个 App 时,安卓按默认把它放到主屏(装了两份时还先在主屏问打开哪一个),Mika 的屏上看不出变化。每个点、滑、按键之类的动作(`AndroidScreen.verified`)前请内置助手盯着主屏 6 秒(`watchMainScreen`):助手向系统登记任务栈监听(ITaskStackListener,只当「变了」的信号,收到就重看任务列表;登记不上时在盯着的那段时间里每 100 毫秒看一次),记下主屏最上面换上来的任务、它之前在哪块屏、属于哪个用户(`app/cap/phone/shell/MainScreenWatch.kt`、`MainScreenLog.kt`),查询(`mainArrivals`)时再当场看一次。动作之后按纯逻辑 `MainScreenSpill` 判:这段时间里冒出来的普通 App 页面(不是桌面、最近任务一类,不是 Mika 自己和桌面 App),不是从别的屏挪过来的(接管),此刻还在主屏,这期间用户没在主屏上点过东西(无障碍的点按事件),也不是原来最上面的那个又露出来,就挪回 Mika 的屏(和接管同一个挪任务的调用 `moveTask`)。**Mika 从不替别的 App 打开或重新打开 App、系统页面**:打开的一方得一直是原来那个 App(比如购物 App 打开付款 App,付款那边要认是谁打开的),挪任务不换打开的一方,重开就换了,所以只挪不重开。动作之前主屏最上面的任务里换了页面,是用户正用着的那个 App 被打开了,不动它;接管中不挪。系统在主屏上问打开哪一个的(系统包的窗口,是系统的选择器、或窗口里的选项对得上装了两份的 App 名;各系统的选择框不一定是原版那个,`MainScreenSpill.handling`)**不挪**:挪了屏它要按新屏重建,有的重建时自己关掉。它留在用户屏上,动作结果带 `opened`(picker、onUserScreen)、认得出的选项 `chooser` 和提示:系统在用户屏上问,别再点、别替用户选,下一个动作会等用户选完。任务在下一个改动屏幕的动作前停住(`TaskRun.awaitUserPick` 接在 `TaskGate` 前面,`AndroidScreen.awaitUserPick`),状态是要你处理的等待(`GateReason.needsYou`,胶囊上是 `CapsuleKind.NeedStep`,「需要你:「」在问用哪个,请在屏幕上选一下」),这期间一直盯着主屏;用户选完(选择框不在了)后看选出来的(`MainScreenSpill.picked`):开到主屏上的普通页面挪回 Mika 的屏接着做,报 inProfile;开在了 Mika 屏上的一并算进来;要用户亲手处理的(窗口里有密码框,或树里读不出东西而截主屏时系统报防截屏窗口,像付款、输密码;只是树读不出的 App 照常挪回,`MainScreenSpill.needsUser`)留在主屏,改成要你处理、可点继续的暂停(「「」要你在屏幕上处理,处理好了点继续」),用户点继续后它还在主屏就挪回来,已经关了就照实说(`byUser`);用户把问题关了(`closed`)就照实说,由模型决定下一步。这些结果在下一次读屏里带 `opened`,下一个动作不做、报 `opened_elsewhere`。结果里的 `opened`(package、app、pulledBack、onUserScreen、inProfile、picker、closed、byUser)配着提示:挪回来了别再点、接着在那里做;挪回来的算目标 App 打开的,不算被切走,效果是换了地方而不是没效果;留在用户屏上(用户正用着)的说别再来、请用户处理。装了两份的 App 在问过打开哪一个之后报开的是哪一份(`inProfile` main/other,按任务所属的用户),另一份时提示用户没要就回去选另一个。动作过了一会儿才开出来的,下一次读屏前挪回来、读屏里带 `opened`,下一个动作不做、报 `opened_elsewhere`。一批动作、手机脚本遇到它就停。
- **接管**:需要用户亲手处理(登录、验证码等)时,`hand_to_user` 发通知;用户点「我来处理」,虚拟屏上的 App 挪到主屏;处理完点「处理好了」或「我自己做完了」挪回去,Agent 先重新读界面核实再继续;「跳过」不算办成(`app/takeover/Takeover.kt`)。多个任务的接管排队。
### web:内置浏览器
- WebView 渲染在 App 自建的私有虚拟显示上(`app/cap/web/WebHost.kt`),不占手机屏幕,也不需要内置助手;默认用桌面版页面(`app/cap/web/cdp/DesktopProfile.kt`),可按网站记住桌面版或手机版(`web_site_mode`)。用户在 Mika 里选定了界面语言(不是跟随系统)时,网页按这种语言请求(`app/cap/web/cdp/PageLanguage.kt`):经 `Emulation.setUserAgentOverride` 的 acceptLanguage 换 Accept-Language 和 navigator.language(s)(如 English → `en-US,en`),桌面版加在桌面身份上,手机版照搬 WebView 默认的 UA 与 UA-CH、只换语言;第一页在会话设好之前就发出去了的,设好后重新加载一次,中途换了语言的标签在下一次主文档加载前换过来;跟随系统时不覆盖,发系统给的那串。
- 通过 WebView 的 DevTools 协议操作页面(`app/cap/web/cdp/WebDevtools.kt`),点击和输入是真实输入事件;连不上时退回注入的页面脚本。从不打开 Runtime 这一组(`Runtime.enable`):开着它时页面察觉得到挂着调试器,真机上有的网站因此一直重载;读脚本和页面脚本只用隔离世界和求值(页面脚本的 mika 调用经世界里的发件箱、由一直挂着的取件求值交回,不用要开 Runtime 的绑定),世界没了靠主框架换了文档(`Page.frameNavigated`)和求值报上下文不在来认。
- 标签页池:每个任务最多 4 个,全局最多 8 个(`app/cap/web/WebTabPool.kt`、`TabBudget.kt`)。页面要跳到外部 App 的链接不跳。
- 下载落到当前对话工作区的 `downloads/`,不打开不安装;上传只交工作区里的文件(`WebDownloads.kt`、`UploadStaging.kt`)。
- 只读脚本:`web_evaluate_js` 在页面的隔离环境里运行,页面对象是只读包装,写、点、跳转一律拦下,也不能联网(fetch、XMLHttpRequest、sendBeacon、WebSocket、EventSource、new Image() 一类报 blocked_network,DevTools 上见到它发出的任何请求也让这次以错误结束;页面背后的数据用 `web_capture` 读页面自己收到的响应),密码框读出来是 `***`(`core/cap/web/ReadScript.kt`)。站点命令(网站知识里带 `/* mika-command` 头的脚本,`core/knowledge/SiteCommands.kt`)经 `web_run_site_command` 同样只读运行;目前内置知识里没有站点命令,这个工具不出现。`web_script` 跑的脚本拿不到页面,只能经 Mika 的动作入口操作。
- 登录状态:网站登记表 `SiteRegistry` 记访问过的站点、是否登录过、是否被验证拦过;设置 → 网站登录可打开登录、单站退出、全部退出(`app/cap/web/WebLogin.kt`)。网页接管把同一个 WebView 挪到主屏的全屏页面(`app/web/WebTakeoverActivity.kt`),表单与登录流程原样保留。
- 用户自己用的浏览器(侧栏「浏览器」,界面在 `app/ui/BrowserScreen.kt`、`BrowserStart.kt`、`BrowserPageParts.kt`,标签在 `app/browser/UserBrowser.kt`):一个看得见的 `WebTab`,和 Agent 的标签同一个网页配置(Cookie、网页存储),在这里登录了 Mika 办事时也是登录的。它不进任何任务的标签池、不占标签上限,也不走网页接管那一次会话(`WebHandover`),任务的标签和接管照常进行。**身份**(`BrowserIdentity`):自己浏览默认手机版;在「更多」里换成电脑版的网站记在 `UserSiteModes`(`browser_site_modes.json`,子域名跟着上级走),下次也用电脑版,和 Agent 的那份(`SiteModes`)分开存;从常用网站打开还没登录的网站是「给 Mika 登录」:整个标签照 Mika 办事时那一版走(`WebDevtools.agentWantsMobile`,默认电脑版),登录留下的 Cookie 和 Mika 办事时看到的网站是同一版,顶上一条横幅「给 Mika 登录 · …」带「登录好了」(用 `LoginProbe` 看一眼:看出没登录就提示,看出已登录或看不出来就信用户、记成已登录、回起始页),页面加载完也照常自己看,看出登录好了横幅收起。规则只装在这个标签上(`WebDevtools.setModePolicy`,每个标签一份,null 时按 `SiteModes`),Agent 标签的身份不变。这个标签是用户亲手看、亲手点的(`WebDevtools.setHandOperated`):电脑版时 UA、UA-CH、身份补丁照旧,但不加视口仿真(Agent 标签用它把整页缩进视图、定死缩放),交给 WebView 自己的宽视口与总览模式,打开时整页缩进屏幕、双指能放大(`DesktopProfile.viewportCall`);换版本时经 `beforeLoad` 把 UA、宽视口和仿真换好再重新打开当前页,中途点到别的网站时由 `onPageStarted` 照规则换过来并重载。**起始页**:顶栏只有返回和「浏览器」;一个大搜索框(「用必应搜索」),输入时下面是联想(`StartPageModel.suggestions`:第一条搜这句话,像网址的是打开它;后面是最近打开里标题或网址对得上的);「常用网站」一行四格(`BrowserFavorites.combine`):用户自己加的在前,后面是站点登记表里的网站,同一网站(可注册域名)一格、打开最短的主机名,这个网站任一主机名登录着就带小绿点(下面一句「已登录,Mika 办事时也能直接用」),最后一格「+」提示去「更多」里加;「整理」或长按进整理,图标轻轻抖(关了动画不抖),× 移除,「完成」退出。常用里只记用户的改动(`browser_favorites.json`:自己加的、移除的),移除只是不显示,登记表、登录状态和 Cookie 都不动(退出在设置页「网站登录」里做)。「最近打开」是这里打开过的最近 10 页(`RecentPages`,同一网址不计 # 只留最近的),搜索写成「搜索:<词>」,网页是网站图标、标题和网站名,右边是相对时间(「10 分钟前」「今天 11:20」「昨天」「10 月 21 日」),标题右边「清除」。网站的名字和图标(`SiteLooks`):页面加载完读页面声明的网站名(og:site_name、application-name),没有就取标题里的一段(首页取开头、别的页取结尾),来历更可靠的才换;图标是 WebView 收到的网站图标(`WebTab.Listener.onIcon`),缩到至多 128 像素存成 PNG;都记在主机名上,可注册域名上还没有(或更差)时也记一份,同一网站别的子域名也认得。都没有时名字取可注册域名去掉后缀(shop.example.com → example),方块是按主机名固定选的底色加第一个字。**网页**:顶上是关闭(回起始页)、地址栏(平时是锁和网站名,搜索页是搜的词,电脑版时带「电脑版」小标签;点了展开成完整网址可改,「取消」收回)、刷新 / 停止和 2dp 进度条;下面是后退 / 前进 / 起始页 / 分享(系统分享面板,网址加标题)/ 更多。「更多」面板:网站(图标、名字、主机名)、刷新、复制链接、在其他浏览器里打开、加入常用网站 / 从常用移除、换成电脑版 / 换回手机版(这个网站下次也这样)。系统返回键先在网页里后退,退不了回到起始页,起始页上(整理、输入中先退出它们)再按回到进来的地方。地址栏和搜索框的输入由 `AddressInput` 判断:带 `://` 的原样打开,localhost、IP 补 http://,像域名的补 https://,其余用必应搜索;打开前过 `WebUrlPolicy.checkNavigable`,打不开的给一句提示。页面加载完停一会儿用 `LoginProbe` 看登录状态:登记表里有的网站按看到的记上或去掉,没有的看出已登录才记上。页面弹框(alert / confirm / prompt / 离开确认)在这里弹给用户回答;开新窗口的链接(用户点出来的)在当前页打开;跳 App 的链接不跳、下载暂不接收,都给一句提示。网页视图放在进程里,转屏时接着用,离开这一页时关掉。
- 一页一页看的节奏(`core/cap/web/PagePacing.kt` 纯逻辑,`PageDwell.kt` 接到任务上):每个动作本身的鼠标键盘节奏之外,同一网站(按可注册域名)连着换页也不秒开。读屏或打开网页时地址变了(不计 `#` 后面)、往下滚时页面自己又收到成批的 JSON 或文本响应(加载更多),都算来了一页;这个网站在最近 10 分钟里的第一页不停留,第二页起停留 4–7 秒,之后每多一页上下限各加 0.6 / 1 秒,最长 12 秒。可能换页的动作(打开同站网址、点击、后退、按键、往下滚、滑动,`web_interact` 的按键)之前补足这一页还没停够的部分,模型思考、读页面花掉的时间都算在内;打字、清空、往上滚、读不等,去别的网站不等。停了多久记进这次调用(`ToolExecution.waitMs` → 运行记录 `tool_result.waitMs`,小结里算等待,`do_steps` 子步的也算进那一批),不另告诉模型;只由 App 的任务装上(`toolDispatcher(webPacing = …)`),核心测试默认不等。
- 站点知识的 `pace: careful`(这个网站限制浏览快慢,见 `docs/knowledge-format.md`)让这个网站的页停得更久:第二页起 10–16 秒,之后每多一页上下限各加 0.6 / 1 秒,最长 30 秒。按当前主机名查站点知识(`GuideCatalog.sitePace`,认站点的规则和注入站点知识同一个 `SiteMatch`),经 `toolDispatcher(webSitePace = …)` 交给 `PageDwell`。
- 一个任务里同一个网站来的页有上限(普通 20 页,`careful` 12 页;过了 10 分钟的时间窗也照算)。到了之后,换页的动作(打开同站网址、点击、后退、按键)不做,报 `page_limit`,说明里让模型先拿已读到的回答、用户要接着看就用 `ask_user` 问;往下滚、读页面照常。用户答过话(回答了追问、交回了接管,经 `Capability.onUserAnswered`)后各网站重新计数——不另给模型提高上限的参数,要多看只能经用户点头。被拒的那次调用照常记成运行记录里的 `tool_result`(`error: page_limit`)。上限只管浏览,不打断成批联系、投递:不可逆判定认出的对外动作(发送、提交)本身不受上限拦(`ActionExecutor` 做之前经 `Capability.onOutwardStart` 告诉网页能力,做完经 `onOutwardEnd`),做成过一次之后这个网站(按可注册域名)在本任务余下的时间里不再设上限、用户答话也不恢复,批量做到网站自己报上限(`limitReached`)、风控或用户叫停为止;停留和对外动作之间的间隔照旧。不设节奏(`PagePacing.Off`)时也不设上限。
- 停留里的小动作(`core/uistate/IdleScroll.kt` 纯逻辑,App 里的停留也用它):停留至少 3 秒、且动作不按屏幕坐标做(按编号点、后退、按键、往下滚、打开网址)时,停留里轻轻滚 1–3 下整页,每下 0.06–0.16 个视口,大多往下、偶尔往回拨一点(不超过之前往下滚的),几下加起来不超过 0.45 个视口、净移动不超过 0.3 个视口;总停留时长不变。按坐标点、滑动的停留里不滚(坐标会对不上)。走端口的 `WebPort.idleScroll`:桌面版用滚轮且指针不动,否则一下慢慢的原生滑动;不移动鼠标、不悬停、不点;有页面弹框、输入法开着、输入框拿着焦点、标签不在显示时不滚。滚了几下只记进运行记录(`tool_result` 结果里的 `idleScrolls`),不告诉模型;滚完、动作之前重看一眼当作「动作前」(`ScreenSession.reprobe`),这点滚动不算成动作的效果,模型手里那一屏标成过时(`interfaceStale`);下一次读屏照实看到滚动后的位置。只由 App 的任务装上(`toolDispatcher(webIdleScroll = …)`),核心默认不滚。
- 风控:网页障碍判定(`WebBarrier`)除了限流状态码、人机验证框架,也认字不多的风控拦截页(访问异常、环境异常、操作过于频繁、unusual traffic,或「安全提示」「Security check」这类标题加上异常、验证一类的字),判成要用户处理(access_blocked),`web_diagnose` 提示交给用户、别马上重试;`web_capture` 取回的响应顶层报信息的字段(message、msg、error…)在说被风控拦下时,结果带上 `riskControl` 和同样的提示。
- 工具:`web_navigate`、`web_observe`、`web_read_page`、`web_list_elements`、`web_tap`、`web_type_text`、`web_clear_text`、`web_scroll`、`web_swipe`、`web_press_key`、`web_back`、`web_tabs`、`web_wait_for`、`web_interact`、`web_downloads`、`web_upload`、`web_capture`、`web_screenshot`、`web_diagnose`、`web_evaluate_js`、`web_script`、`web_site_mode`,以及看图类工具。
### sandbox:Linux 沙箱
- 基于 PRoot 和 Alpine 3.22 的运行环境,随 APK 自带,App 启动时在后台离线解包(`app/cap/sandbox/RuntimePreparer.kt`、`RootfsInstaller.kt`);Mika 升级后自动重新解包。沙箱程序只有 arm64 版本,其他设备显示「此设备不支持(需要 arm64)」,模型也被告知不用这组工具。来源与构建见 `tools/sandbox/README.md`。
- 每个对话一个工作区,沙箱里是 `/workspace`,跨消息保留;聊天里附加的文件放进 `/workspace/inbox/`;`share_file` 把文件存到手机的「下载/Mika」并在对话里显示为文件卡片;点卡片时网页、Markdown、PDF、图片和文本类文件在 App 里的查看页打开(`FileViewerActivity`,见第 15 节),其余交给系统里能打开它的 App。
- 默认断网,设置 → 高级 → 文件与计算环境里「允许脚本联网」打开后才联网;设备无法隔离网络时,只有打开「允许脚本联网」才能运行(`AndroidSandbox`、`AdvancedSection` 的提示)。每个任务有累计运行时间预算,同一条命令反复失败会被叫停(`core/cap/sandbox/SandboxGuard.kt`)。
- 工具:`run_command`、`run_bundled_script`(运行知识、技能自带的脚本,见 `docs/sandbox/skill-scripts.md`)、`read_file`、`write_file`、`search_files`、`share_file`。
- 沙箱里的命令可以用 `mika phone <操作> ''` 调当前任务的手机工具(`app/cap/sandbox/SandboxBridge.kt`、`BridgeProtocol.kt`):每次命令运行一个随机名字的本地套接字和一次性令牌,开放哪些由工具自己声明(`ToolInfo.sandboxCallable`:手机能力的工具默认开放,看图类不开放;新增手机工具不用改名单),`<操作>` 是工具名去掉 `phone_`(`type`、`clear`、`key`、`launch`、`dump` 是 `phone_type_text`、`phone_clear_text`、`phone_press_key`、`phone_launch_app`、`phone_list_elements` 的简写)。每次操作仍经 `SubActions` 回到唯一入口并写进运行记录,超时与上限和直接调用相同(`phone_wait` / `phone_event` 最长 30 秒,`phone_system_command` 10 秒)。另有 `mika share`、`mika progress`。
### 看图
有能看图的模型时(§12),提供 `phone_describe`/`web_describe`(描述界面)和 `phone_find_visually`/`web_find_visually`(看图定位,`core/vision/Locator.kt`:先在控件树里精确找,找不到才让模型框,再核对名字;定位准度没验证过的模型要有佐证,但这一屏的树不能用、或缺了一块而要找的不在树里时没东西可对照,框照样交出去,标 `unverified`,点完由效果说话)。描述界面一次要读整屏、写一大段,比定位慢好几倍:提示词里只让它在看不懂是什么页面时用,描述的要求(`roles/describe.md`)也只要简短的四段——可交互元素至多 15 行、每个几个字,不列不是控件的字,长列表只给前几项,线索至多两行。截图工具只在主模型能看图时提供。图形验证码不自己解,交给用户。
### 子任务
`spawn_agent`(`core/general/SpawnAgent.kt`、`core/child/ChildRuns.kt`)派一个子任务去查一个有边界的问题:只有网页和沙箱、只读、无人在场、最多 15 步,不能追问、不能对外发送、不能写记忆、不能建定时、不能再派子任务,也没有知识与技能查阅工具。结果有长度上限,作为不可信的观察交回父任务;子任务做完不等于用户的目标完成。只在对话界面发起、有网页能力的运行里提供。
### MCP
`core/mcp/McpServer.kt`(低层工具会话、整任务提交)和 `core/cap/mcp/McpClient.kt`(按注解定风险、工具列表开局冻结)只有骨架和测试,没有传输层,用户用不到。App 一侧已有 `McpTaskHub`(`app/task/McpTaskHub.kt`)和 `TaskService` 的 `ACTION_START_MCP` / `ACTION_CANCEL_MCP`,等传输层接上就能收任务、取消任务。
## 7. 上下文与压缩
- **缓存分区**(`core/agent/ContextBuilder.kt`、`ContextCompressor.kt`):区 0 是系统提示词和工具定义,一个任务内只在展开一组工具时变(每组至多一次,只改从那一组起的部分),其余时候一字不变;区 1 是开局上下文,写一次不改(以前的对话、记忆与画像、任务原话、可用渠道、App/网站知识、技能索引、个人技能;除用户原话外都包在 `` 里);区 2 是历史,只追加;区 3 是本步的结果、画面和运行时状态(在哪个界面、第几步、当地时间,附在最后一条消息末尾)。主循环写给模型的文字(外壳的来源与可信度、运行时状态、提醒、确认与接管的说明、回放经过、工具结果、折叠摘要)一律是英文,和系统提示词、工具说明一致,免得模型跟着中文回答;给用户看的文字照旧按界面语言。
- **只追加**:平时已发出的消息不改写,完整历史从不被修改;只有压缩时在副本上统一改写一次。
- **何时压缩**:估算的输入量超过模型上下文的 0.6(扣掉固定部分)时。估算按字数粗算,并用服务商返回的真实用量校准。压缩后降到触发线的一半以下,有回差避免连着压,但不会拖过上下文的 0.9。服务商报上下文超长时,改用更激进的档位重发一次。上下文长度来自模型配置。
- **怎么压**(不调模型):先缩短最近 12 轮之前的旧结果(旧的界面读取换成占位,网页、命令、文件内容超长才截;占位保留观察编号),再把旧轮次折叠成「Earlier steps (compressed)」;不够再逐档收紧。结构化摘要(`StepSummary`)有实现,默认关。
- **永不裁剪**:用户说的话(任务原话、插话、回答)从不裁剪;追问、确认、接管那几轮不折叠。压缩后 `ContextRecovery` 把缺了的用户消息和开局上下文(以前的对话、记忆、知识、技能、渠道)补回原位。
## 8. 并行
不同对话的任务可以同时进行,每个任务一块虚拟屏(`app/task/ParallelCapability.kt`):需要安卓 14 及以上、打开速度开关「不同对话同时进行」(默认关),上限 1–3(默认 2);低内存设备或第二块屏建失败过时只允许 1 个。真正独占的是同一个 App、主屏接管和剪贴板,由 `ResourceScheduler`(`app/task/ResourceScheduler.kt`)统一协调:同一个 App 同时只归一个任务,接管排队,剪贴板加锁,网页标签页有总预算。
## 9. 经验回放
成功路线可记录下来,同类任务直接回放、每步轻量核对、不调模型,对不上就交还模型(`core/replay/`,回放的动作以 `Origin.Replay` 经唯一入口执行)。路线只从办成且核实过的运行里记录(核查目前关着,见 roadmap「已知问题」第 1 条,所以现在记不下路线)。速度开关「照着上次的做法做」默认关。
## 10. 定时与条件触发
- **工具**:`schedule_task`(`core/general/ScheduleTask.kt`),`op` = create(默认)/ list / pause / resume / delete / run_now / reschedule。只在对话界面发起的运行里提供。
- **时间规则**(`TriggerDefinition`,`core/trigger/Triggers.kt`):一次性(`at`);固定间隔(`intervalMinutes`,错过多次合并成一次并记下错过几次);按当地时间每天或每周几(`repeatAt` + `repeatZone` + `weekdays`),跨夏令时保持钟点不变。
- **三种做法**:固定提醒(`reminder`)到点直接送达,不调模型;办事任务到点作为无人在场的 Run 执行;带 `condition` 的先用只读的网页和沙箱检查条件,结果结构化为 `ConditionOutcome`(`core/trigger/ConditionOutcome.kt`),检查出错不算「不满足」;只在条件从不满足变为满足时才执行动作。`whenIdle` 让它等到没有任务在跑时再做。
- **调度**(`app/task/TriggerRuntime.kt`):系统闹钟只是唤醒信号,`FileTriggerStore` 是真源。派发前先写好下次时间和这一次的窗口(编号 = 触发器 + 到期时间),同一窗口不会派发两次。能用精确闹钟就用,没有权限时第一次会发通知引导用户去系统设置打开,`schedule_task` 的结果也告诉模型「可能不准时」;计划页显示提示。开机、改时间、改时区、精确闹钟权限变化时重新布置(`app/setup/BootRestoreReceiver.kt`),App 启动时也会。
- **结果回原对话**:到点的任务就在创建它的对话里运行,结果、提醒和失败说明都写回那个对话,通知点开就是它。
- **进程中途被杀**:提醒重新送达(按编号去重);其他已认领的窗口标为结果不明,在原对话里说明并通知「Mika 不会自动重复操作」。
- **计划页**(`app/ui/PlansScreen.kt`):分「进行中」「已停用」两段,每条三行(做什么、重复规则与下次时间或状态、上次结果),右边开关即暂停与恢复;点一行升起操作面板:看对话、立即执行、改时间、删除。
## 11. 知识与技能
- **内置**(`core/src/main/resources/`):App 知识(`knowledge/apps//APP.md`)、网站知识(`knowledge/sites/<域名>/SITE.md`)、技能(`skills//SKILL.md`),一个目录一份,加一份就是加一个目录;现有哪些看这几个目录或构建生成的索引 `knowledge/index.json`,README 里的清单由 `./gradlew updateReadme` 从同一份索引生成。格式见 [`knowledge-format.md`](knowledge-format.md)。构建时 `KnowledgeIndex` 扫描头部、校验并生成索引,校验不过构建失败。
- **自动补给**:开局按任务文字匹配;进入某个 App(读过它的界面)或某个网站(第一次成功打开或读取)时补上它的知识正文。每份只给一次,每个任务最多 3 份,关联的技能加进技能索引(`KnowledgeStore`、`NoticePolicy`)。
- **查阅工具**(`core/general/LookupTools.kt`):`knowledge_search` / `knowledge_read` 查 App 与网站知识,`skill_search` / `skill_read` 查技能(内置的和个人的一起)。
- **个人技能自学**(`core/skills/`、`app/task/RunLearning.kt`):任务办成后,按条件起草一份个人技能(动作够多、办成并核实、没有跳过的接管、不是回放出来的)。起草要求运行「核实过」(`RunLearning`),而 `reviewFinalAnswer = false`,所以现在不会发生,见 roadmap「已知问题」第 1 条;草稿要用户在「记忆与技能」里收下才试用,试用时核实成功转为正式,失败就停用;正式技能近 3 次里有 2 次不好也停用(`SkillOutcomes`)。同样的流程也用来起草 App 知识草稿(`core/knowledge/AppKnowledgeLearning.kt`),试用期注入时标「未核实」。开关「从办成的任务里学技能」默认关。
## 12. 记忆与模型
- **记忆**(`core/memory/`):三类 `MemoryKind`——事实、偏好、做法。事实和偏好记下即用、可撤销,做法要用户批准。只从用户自己说的话里记(`UserUtterances`),不从屏幕和网页内容里记;密码、验证码、卡号、证件号等由 `SensitiveFilter` 挡掉。工具 `memory_save`、`memory_search`;任务结束后 `MemoryExtractor` 在后台从用户原话里提取(读过网页的运行不提取;还要求这次运行由对话界面发起、交出了回答,`RunLearning`)。开局注入一小块相关记忆(`MemoryContext`)。另有单独的用户画像(`ProfileStore`,工具 `profile_edit`,改动从下一个任务起生效)。记忆和画像用安卓密钥库里的密钥加密存储(`app/memory/EncryptedMemoryFile.kt`)。开关「使用记忆」「自动记住我说过的事」默认开。
- **模型**:四种接口——OpenAI 兼容(Chat Completions)、OpenAI Responses、Anthropic、Gemini(`core/model/`);另可用 ChatGPT 账号登录、用套餐额度(见下「Responses 与用 ChatGPT 登录」)。模型分三个用途 `ModelRole`:主模型、看图模型、轻量模型。每个用途选什么都是用户明说的(`RoleChoices`,在 `app/settings/ProviderStore.kt`),没有按能力推断的默认分配:主模型是一个具体模型或还没选;看图是一个具体模型、「用主模型」或「不用」(新装默认不用);轻量是一个具体模型或「用主模型」(新装默认)。选择不按能力标记拦(`ProviderStore.choose` 不拒绝),选具体模型时把对应标记记下:选作主模型记工具调用、选作看图模型记看图,运行时按这两个标记决定发不发工具定义、附不附图;看图「用主模型」时 `configFor` 给出主模型那条配置并把看图标记当真(用户说了它能看图),主模型自己办事仍按它存着的标记。能力标签只作参考。结局判定、记忆提取、意图判断、路线匹配优先用轻量模型;打开速度开关「简单步骤交给轻量模型」后简单步骤也交给它(`core/agent/ModelRouter.kt`,轻量模型不支持工具调用时不接手机步)。「看图定位已验证」(`Capabilities.groundingVerified`)在设置 › 开发者选项里改,作用在当前看图模型那条配置上(`ProviderStore.setVisionGroundingVerified`),没有看图模型时不显示。API Key 用密钥库加密保存(`app/settings/KeyCipher.kt`)。
- **服务商预设**(`app/settings/ProviderPresets.kt`):预填接口类型、地址和能力初值,模型名和 Key 用户填。国内:DeepSeek、通义千问、Kimi、智谱、硅基流动、火山方舟(豆包,模型名可填模型 ID 或接入点 `ep-…`)、MiniMax(国内站 `api.minimax.cn`)、阶跃星辰、腾讯混元(TokenHub `tokenhub.tencentmaas.com`)、百度千帆(`qianfan.baidubce.com/v2`);国外:OpenRouter、OpenCode Go、Anthropic、Gemini、OpenAI、xAI、Mistral、Groq;自己机器上跑的:Ollama、LM Studio;最后是「自定义」。「OpenAI」的接口类型是 Responses(`ProviderConfig.Kind.OpenAiResponses`):官方文档写明新的主力模型调用工具必须走 Responses,Chat Completions 带工具会被拒;列模型同样是 `GET /models`。其余都是 OpenAI 兼容。添加「自定义」服务商时接口类型可选 OpenAI 兼容、Anthropic、Gemini、OpenAI Responses(`ProviderConfig.Kind.withApiKey`,Responses 排最后),选 Responses 的经 `ResponsesModel` 调 `{base}/responses`,列模型照样是 `GET {base}/models`;自定义的 Responses 和 Anthropic、Gemini 一样要填 Key(自定义服务商里只有 OpenAI 兼容可以不填,多半是本地服务,见 `AddForm.keyRequired`)。`ProviderPreset.selfHosted` 的(Ollama、LM Studio)地址预填本机默认端口(`http://127.0.0.1:11434/v1`、`http://127.0.0.1:1234/v1`),表单里能改成电脑的局域网地址(`AddForm.urlEditable`,下面一句提示怎么改),Key 可不填(`AddForm.keyRequired`),存下的服务商名字仍用预设名。README 里的预设清单由 `./gradlew updateReadme` 从这个文件生成(见 `ReadmeSections`)。
- **列模型与连接服务商**:`ModelCatalog`(`core/model/ModelCatalog.kt`)调各家公开的列模型接口(OpenAI 兼容 `GET /models`、Anthropic `GET /v1/models`、Gemini `GET /v1beta/models`,后两者翻页),返回模型名和接口给出的能力(工具调用、看图、上下文长度、推理);接口没给的为空,由 `ModelOptions`(`app/settings/ModelOptions.kt`)沿用预设的初值并标为未确认。失败按原因分类(Key 无效、网络、不提供列表等)。列表接口 404 / 405 / 501 记为「不提供列表」,但单看它分不出这家真不提供列表还是地址、接口类型填错了,由下面的试调用来分。
- **一个服务商、几种协议**:有的服务商(如订阅制的聚合服务)一个 Key 下的模型走不同协议,列表里每个模型带一张 `api` 表,键是协议(`chat-completions`、`messages`、`generate-content`、`responses`…),值里有这个协议的 `base_url`(兼容接口还可写 `compatibility.reasoning_field`)。`ModelCatalog` 按 Chat Completions、Messages、generateContent、Responses 的次序取第一种会说的,记成 `ListedModel.endpoint`(`ModelEndpoint`:协议、地址、思考字段);一种也不会说的(如 `realtime`)记下协议名(`unsupportedApis`),`ListedModel.usable` 为假,`ModelOptions.merge` 不列出来、服务商的模型数也不算它,用户选不到必然失败的模型。同一种写法里的能力也认:`capabilities.tools.supported`、`capabilities.reasoning.supported`、`limit.context`、`modalities.input`(有 `image/…` 即能看图)。`endpoint` 随模型列表存在 `providers.json` 的 `services[].models[].api` 里(`unsupportedApis` 同处)。建模型时 `ProviderStore.modelFor` 现查服务商记住的列表里这个模型的 `endpoint`,有就按协议选适配器(`ProviderConfig.Kind.of`:Chat Completions → `OpenAiCompatModel`、Messages → `AnthropicModel`、generateContent → `GeminiModel`、Responses → `ResponsesModel`)、用它的地址,没有就照旧按服务商的接口类型和地址;重新列过、地址换了,下一次建模型就用新的。适配器自己接版本路径(Anthropic 接 `/v1/messages`、Gemini 接 `/v1beta/models/…`),列表地址末尾已有的 `/v1`、`/v1beta` 由 `ModelEndpoint.adapterBaseUrl` 去掉,不会重复。Key 都用服务商那一个。兼容接口的思考回传(`OpenAiCompatModel`,不按服务商名字、地址或模型名判断):各家把思考放在 `reasoning_content`、`reasoning`,或 `content` 是分块数组、思考在 `thinking` 块里(文字块是回答);收到的思考记下收到它的地址和位置(`ReasoningVia`,存在 `Part.Thinking.opaque`),带工具的请求里原样放回原处(字段同名;分块的那家整条按分块放回,思考块在前)。只回传从同一个地址收到的,别的地址、别的适配器给的思考不发,不认这些字段的服务商不会收到;不带工具的旁路调用不回传。列表写明了 `reasoning_field`(`reasoning_content` 或 `reasoning`)的,来历不明的思考(旧版本存下的历史)也按它回传。预设「OpenCode Go」就是这样:列表地址 `https://opencode.ai/zen/go/v1`(列表不要 Key),接口类型 OpenAI 兼容只是没写协议时的退路;只写了 `responses` 的模型(地址如 `https://opencode.ai/inference/go/openai/v1`)由 `ResponsesModel` 调用。
- **Responses 与用 ChatGPT 登录**:`ResponsesModel`(`core/model/ResponsesModel.kt`)调 `POST {base}/responses`,一律 `stream:true`、`store:false`,每次把上下文都放进 `input` 数组:系统提示词放 `instructions`,对话里的系统消息改成 developer 消息,图片是 `input_image`(模型不能看图时换成一句占位),工具是 function 工具(`strict:false`),工具调用和结果是 `function_call` / `function_call_output` 条目。只发 `model`、`instructions`、`input`、`tools`、`store`、`stream`、`include`,温度、输出上限等可选字段一概不发(用 ChatGPT 套餐额度时不收)。推理模型的思考请求加密内容(`include: reasoning.encrypted_content`),原样存进这一回合第一个工具调用的 `Part.ToolCall.opaque`,回放时放回工具调用前面(去掉只在服务端有意义的 `id`);服务端说不收 `include` 时去掉再发一次。只有收到 `response.completed`(或 `response.incomplete`:`max_output_tokens` 记为输出被截断)才算成功,`response.failed` / `error` 按 `error.code` 归到 `ModelError`,流在收尾前断开当作网络故障(会重试)。令牌由 `BearerToken` 给:API Key 固定不变;能换新的(ChatGPT)遇到 HTTP 401 先换一个再发一次。用量从 `response.usage`(`input_tokens`、`output_tokens`、`input_tokens_details.cached_tokens`)记进 Token 用量。
「用 ChatGPT 登录」(`core/chatgpt/`,接口类型 `ProviderConfig.Kind.ChatGpt`)走 OpenAI 给开源、本机运行的 App 的那条路:公开客户端、没有密钥,授权码 + PKCE(S256),每次登录新生成 state、nonce、校验串。`LoopbackCallback` 在 `127.0.0.1` 上开一个系统挑的空闲端口,只收 `GET /auth/callback` 一次(别的路径回 404),回一页「可以回到 Mika」(页上的按钮是打开 Mika 的 intent 链接),超时或取消就关掉。授权页 `https://auth.openai.com/api/accounts/authorize` 用系统浏览器打开:第一次用 `client_id=dynamic_agent_client` 注册,带 `agent_name_hint=Mika` 和本机标识 `ext_agent_host_id`(`urn:uuid:…`,每台设备生成一次);回调给出这个账号专属的 client_id,换令牌前就记下(`settings/chatgpt-host.json`,和本机标识放在一起,都不是密钥),之后换令牌、刷新、再登录都用它(再登录带上次的 ID 令牌和邮箱当提示,退出登录后不带 ID 令牌)。`scope` 是 `openid profile email offline_access resource.invoke chatgpt.tokens.use.direct`,`resource` 是 `https://api.openai.com/v1`。换令牌(`/api/accounts/oauth/token`,表单)后验 ID 令牌:按 `/.well-known/jwks.json` 验 RS256 签名,核对签发方、受众(= client_id)、过期和 nonce;授予的权限里没有 `chatgpt.tokens.use.direct` 就不算登录成功(`signInWithChatGpt` 把这些串起来)。整份登录(`ChatGptSession`:client_id、身份、令牌、到期时间、授予的权限)当作这家服务商的「Key」,经同一个 `KeyCipher` 加密存在 `provider-keys.json`。访问令牌一小时过期:`ChatGptTokens` 在快到期(不到 5 分钟)或被拒时用刷新令牌换一组新的,新的刷新令牌和访问令牌一起存下,同一时刻只有一个在换;刷新令牌不能再用(`invalid_grant` 等)就清掉令牌、报「登录已失效」,网络或服务端一时出错令牌留着。退出登录先在服务端注销刷新令牌(`/api/accounts/oauth/revoke`),确认不了也清掉本机令牌并说明;服务商、模型和用途都留着,再登录接着用。删掉 ChatGPT 服务商时忘掉 client_id(下次重新注册,可以换账号),本机标识留着。模型列表是 `GET https://api.openai.com/v1/models`(访问令牌),返回的 `models` 表只留 `visibility` 为 `list` 的、按服务端次序,名字取 `slug`、显示名取 `display_name`,`input_modalities`、`context_window`、`supported_reasoning_levels` 给了就填能力;都经 `ResponsesModel` 调用(`chatGptPlan`)。套餐额度的错误:`subscription_sharing_usage_limit_exceeded`(或 HTTP 429)是额度到了上限(`ModelError.ChatGptLimitReached`),`subscription_sharing_user_not_eligible`(或 HTTP 403)是账号不能这样用(`ChatGptNotEligible`),都不重试,任务结局里说清原因和去哪看用量(chatgpt.com/settings/usage);`subscription_sharing_usage_unavailable` 等一时不可用的按 503 稍后重试。界面:添加服务商表单顶上一张「用你的 ChatGPT 套餐」卡和「使用 ChatGPT 继续」按钮(写明 Plus、Pro 可用、Mika 不另收费),第一次登录成功后说明一次用量走 ChatGPT 套餐;服务商详情里是账号(邮箱)、管理用量(打开 chatgpt.com/settings/usage)、退出登录或再登录,没有接口类型、地址和 Key。
- **请求头与会话号**(`core/model/ClientHeaders.kt`):发给模型服务的每个请求(四种协议的适配器 Chat Completions、Messages、generateContent、Responses,一次性和流式,以及 `ModelCatalog` 列模型)都带 `ClientHeaders.of` 给的头,Key(或 ChatGPT 的访问令牌)的头仍由适配器自己加;「用 ChatGPT 登录」也带 `User-Agent`,它没声明会话头,不发会话号。`User-Agent` 是 `Mika/<版本>`(App 用 `BuildConfig.VERSION_NAME` 交给 `ProviderStore`,core 不写版本号)。有的服务商要求每段对话带一个固定的会话号(据此路由、复用提示词缓存,不带就拒绝):预设用 `ProviderPreset.sessionHeader` 声明头名(目前只有「OpenCode Go」的 `x-opencode-session`),连上时记在服务商上(`Provider.sessionHeader`,存在 `providers.json` 的 `services[].sessionHeader`;旧版本存下的没有这一项,读入时按地址对得上的预设补上)。没声明的服务商不发会话号,代码里不按服务商名字判断。会话号是 `ModelSession.of(对话 id)`(UUID v3,不可逆,不把对话 id 本身发出去):`TaskEnvironment` 的三个模型工厂多一个 `ModelSession` 参数,`TaskRun` 按自己的对话建出主、轻量、看图模型,核查、意图判断、结局判定、停止后的交代都用这几个,`TaskService` 起对话标题时也按同一个对话 id 取;同一段对话里每次运行、每个旁路调用的会话号都一样,换一段对话就不同。不属于任何对话的请求(列模型、连接测试)每次用 `ModelSession.fresh()` 的新号。
- **明文 http**(`res/xml/network_security_config.xml`,清单里 `networkSecurityConfig` 引用):用户自己在本机、局域网跑的模型服务多半只有 http,所以整个 App 允许明文(`base-config cleartextTrafficPermitted="true"`),证书只信系统的。代码里没有别处拒绝 http:`ModelCatalog` 和适配器只要求是合法的 http(s) 地址,添加表单只要求以 `http://` 或 `https://` 开头。添加服务商时,明文 http 又不在本机或局域网(`LocalHosts.isLocal`:localhost、127/8、::1、10/8、172.16/12、192.168/16、169.254/16、fc00::/7、fe80::/10、`.local`)的地址在表单下提醒一句(`AddForm.unencryptedWarning`),不拦着。内置浏览器因此也能直接打开 http 网页:以前系统先拒绝(`ERR_CLEARTEXT_NOT_PERMITTED`)、`WebScreen` 再换 https 重开,现在按原地址打开,那段换 https 的重试只在设备策略或 WebView 仍拒绝明文时才用得上;https 页面里的明文子资源仍按 `MIXED_CONTENT_NEVER_ALLOW` 拒绝。
- **服务商与模型配置的存储**(`app/settings/ProviderStore.kt`):`Provider` 是一个已连接的服务商(id、名字、接口类型、地址,和最近一次列出的模型),`ProviderConfig` 是挂在它下面的一个模型(记 `providerId`,接口类型、地址照抄服务商的,建模型时 Key 取服务商的)。模型配置是存储内部的一层,用户看不到:选模型时当场建(`ProviderStore.use`),没有用途再用它就删掉(每次改用途、删服务商和读入时整理;Key 按服务商存,删配置不动 Key)。`providers.json` 里 `services` 存服务商(含记住的模型列表)、`providers` 存模型配置、`uses` 存三个用途的选择(`{"use":"model","config":<配置 id>}`、`{"use":"main"}` 或 `{"use":"none"}`);Key 经 `KeyCipher` 加密后存在 `provider-keys.json`,每个服务商一份。旧版本只有模型配置、Key 按配置 id 存:读入时按 `ProviderConnection.keyOf`(接口类型、地址、名字)归成服务商,Key 取组里任一条的,再清掉按配置 id 存的。旧版本的 `roles` 只记显式指定的配置 id:读入时把那时实际生效的分配写成 `uses`——主模型没指定就取第一个配置,看图没指定时主模型能看图就「用主模型」、否则不用,轻量没指定就「用主模型」(都只做一次,已迁移好的不再动,重复执行没有副作用)。删除服务商时它下面的配置和 Key 一起删,用着它的用途退回默认(主模型变成没选,看图不用,轻量用主模型)。
- **配置模型页**(`app/ui/ModelSetupScreen.kt`,状态在 `ModelSetupViewModel`):上手时的「配置模型」和设置 ›「模型」(`app/ui/ModelsScreen.kt` 只是不带引导的同一页)共用页面和状态,用户只管两块:用途(三张卡,每张写明选了什么:模型名、「用主模型」和主模型是谁、「不用」或未设置)和服务商(每家一行,写明在给哪些用途干活)。上手时服务商在前、没有服务商时添加表单直接摊在页面上,底部「下一步」选好主模型才可点;设置里用途在前,没有底部按钮,改动当场生效。添加服务商选预设或自定义、填 Key 后「测试并保存」:先列一次模型,列得出(或服务不提供列表)后再试一次真调用——按 `ModelOptions.candidates` 的先后(这家自己的模型在前:模型名或显示名里有预设的 `ProviderPreset.familyTokens`,如通义千问的 qwen;再是列表明说能调用工具的;其余照列表顺序;明说不会调用工具的、像向量 / 重排 / 语音 / 画图的不试——列表的 `type`、输出模态写明了照它(`ListedModel.chat`),没写看模型名里的词;一个候选都没有就试第一个)给模型一个无参数的工具 `get_time`、请它调用,回复上限 1024 个 token(够一次工具调用连同先想一想的模型的思考;Messages、generateContent 给;Chat Completions 不给,`max_tokens` 和 `max_completion_tokens` 各家认的不同,给错整个请求被拒;Responses 也不给,ChatGPT 套餐那条路不收 `max_output_tokens`),和真实任务走同一条建模型的路(`ProviderStore.probe` 用同一个 `modelFactory`,请求头、会话号照带,会话号用新的),最多等 20 秒(用 ChatGPT 登录不走这张表单,不试:登录本身就核对过了);Key 不对、少了服务商要的请求头在这里就露出来,服务端的报错原样显示、不存。`ProviderStore.probe` 回 `ProbeResult`:回了工具调用(`ToolCalled`)算通过;只回了话(`NoToolCall`)说明接口通、这个模型却不会调用工具。没通过、是那个模型自己的问题(只回了话,或适配器给出 `ModelError.ModelNotAvailable` 这个账号没开通或没权限、`ModelNotFound`、`ToolsUnsupported`)时接着试下一个候选,至多 3 个(`ModelSetupViewModel.PROBE_CANDIDATES`):通过的存为主模型,页面在服务商列表下说一句先试的哪些没通过(`ModelSetupUi.probeNote`);都没通过照样存下服务商(Key 已证明是对的)、不定主模型,说第一个为什么没通过、试了哪些,下面一个「选主模型」,这家记在 `ModelSetupUi.pickToProbe`,在它里面选主模型时先试调用(`ModelSetupUi.probeBeforePick` → `pickManual`),不通在那家下面说原因、不选;再测试或选好主模型时收起。第一个就因为别的原因失败(Key、网络、地址、超时、服务端出错)不存;之后的候选因为别的原因失败就不再试,按都没通过处理。适配器认「这个模型不行」用同一个 `core/model/ModelAccessErrors.kt`:HTTP 400 / 403 / 404 的报错内容里有不支持工具(「does not support tools」…)、这个账号用不了(「not activated」「not authorized to access this model」「does not have access to the model」「Model.AccessDenied」「未开通」「无权限」…,带上服务端原话)、没有这个模型(400 / 403 里的「model_not_found」「model not exist」…)这几类说法时分别给出 `ToolsUnsupported`、`ModelNotAvailable`、`ModelNotFound`,其余照各适配器按状态码处理,401 一律是 Key 的问题。没列出模型(列表接口不存在,或列出来是空的)时不直接存:表单要一个模型名(`AddForm.noList`,输入框的例子按接口类型,`ModelSetupCopy.modelExample`),再测试就用它试调用,通了存成不提供列表的服务商并拿它当主模型,不通不存;试调用回 404 时适配器给出 `ModelError.ModelNotFound(model)`,表单说清是哪个模型、模型名大概长什么样,自定义的还说地址或接口类型可能填错了。自定义填的地址主机名和某个预设一样(`ProviderPresets.forHost`,自己跑的服务除外)时提醒改用那个预设,一点就换、Key 留着;地址就是预设的那个、接口类型却不同时一并说。试调用得到回复的模型名记在 `Provider.verifiedModels`(随服务商存下;旧文件没有,那时不提供列表的不试调用)。都成了才经 `ProviderStore.connect` 存下这家和列表(同接口类型、地址、名字的更新原来那家),名字是预设名;自定义的可以在表单里起名字(`AddForm.name`,至多 `ModelSetupViewModel.MAX_NAME` 个字符(按码点算,emoji 不会被截成半个),不填用 `AddForm.defaultName`:地址的主机名,地址是某家预设的用预设名,输入框的提示里写着这个默认名),同一个地址起不同的名字存成两家(例如同一个中转站的两个 Key),列不出说原因、不存。连上第一家且还没有主模型时自动选它的一个模型。选用途页(在 `app/ui/ModelsScreen.kt`)看图顶上有「用主模型」「不用」、轻量顶上有「用主模型」,下面按服务商分组列全部模型(多时可按名字筛),标签只作参考、不过滤不置灰,不提供列表的服务商可手填模型名,手填的先试调用(`ModelSetupViewModel.pickManual`,主模型还要会调用工具),通了才选上、记为核实过,不通在那家下面说原因;其余点一下即生效并返回。不提供列表的服务商里在用、却没核实过的模型(`ModelSetupUi.unverified`),服务商那一行提醒「没测试通过」,详情页有「重新测试」(`ModelSetupViewModel.retest`)。任务里模型调用 404 时,失败原因写明哪个模型不存在、去设置 › 模型检查模型名或改用对应的预设;这个账号用不了的模型写明是哪个、去设置 › 模型换一个或到服务商后台开通;不支持工具的写明换一个会调用工具的(`core/agent/StopRules.kt` 的 `userReason`;这几种轻量模型遇到了,本任务余下的步都用主模型)。服务商详情(同一文件)是连接(状态、接口、地址、Key 可更换)、最多三个模型(在用的在前,标着给哪个用途干活)和「查看全部 N 个模型」、删除(删除前说清哪些用途要重新选);「全部模型」页搜索框固定在顶上,右上角刷新重新列一次。点任一模型从底部弹出「用作」主模型 / 看图模型 / 轻量模型,和选用途页走同一个操作。三个用途的名字、做什么和说明只在 `ModelsCopy` 里写一次。打开选用途页或服务商页时,还没记住列表的服务商(例如旧版本迁移来的)列一次。
- **Token 用量**(`core/usage/`):在源头记账,不靠各调用方自己记。App 里能调用的模型只从 `ProviderStore.modelFor` 拿(`buildModel` 是它私有的),出去前一律经 `metered` 套上计量(`MeteredModel.kt`):名字、能力照旧,异常原样抛出,逐段文字照常回调;每次调用结束记一笔——服务商 id、当时的服务商名、模型名、用途(`UsagePurpose`,就是拿模型时的 `ModelRole`:「用主模型」的看图、轻量调用记在看图、轻量名下),带用量的记输入(含缓存命中)、输出、缓存命中的 token,没报用量的、抛错的(含取消)只记次数,不估算。主循环、结局判定、意图判断、看图、记忆提取、路线匹配、技能与 App 知识起草等旁路调用因此全都算上;列模型不是对话调用,不记。账本 `UsageLedger` 按 UTC 整点 × 服务商 × 模型 × 用途累计,只存在本机 `files/usage-v1.json`(和其他设置一样是 JSON;键表一份,行是紧凑数组):记账只在内存里加,多线程安全,改动后约 2 秒由后台线程整份原子写出,进程被杀最多丢最后几秒;只留 400 天。统计 `UsageQuery` 是纯函数,本地时区在统计时换算:「24 小时」是滚动的最近 24 个整点(含此刻所在的小时)按小时分,「7 / 30 / 90 天」是含今天的本地日历日、「自定义」是本地起止日期(含两头),按天分,超过 45 天按周分(从第一天起每 7 天一格,`UsageStep`);一个整点桶按它开始的时刻落到本地那天(夏令时那天照样对)。每格带各服务商的 token,供叠起来画。每个服务商有固定的先后(`ProviderUsage.order`:连着的按连接先后,删掉的排在后面、按最早记账的时间),配色按它取主题里的分类色(`MikaColors.series` 三种,其余灰色、在趋势图里归「其他」),换范围、选服务商都不变色;文字一律用 ink 色。服务商删了历史还在,用记账时的名字加「(已删除)」。用途只作为存储的一个维度,界面上不分用途、不显示价钱。设置 ›「Token 用量」一行写近 7 天合计;点进去是一个页面(`app/ui/UsageScreen.kt`,没有下一层):时间(24 小时 / 7 天 / 30 天 / 90 天 / 自定义,下面一行写实际范围,自定义的带「改日期」)、总用量卡(始终是全部服务商:大数字、输入 / 输出 / 缓存命中 / 调用次数),然后三张图——「用量趋势」(按服务商叠起来的柱子,图例、两条虚线网格和右侧刻度,点一根看这一格的合计和各家用量)、「按服务商」横条排行(用量和占比,点一家选中、再点取消,选中时别家变淡、趋势图里别家淡到四分之一)、「按模型」横条排行(上面一排胶囊和服务商排行是同一个选择;选了只列这家的模型,横条是它服务商的颜色,占比按这一排算);页脚写没报用量的次数和「数字以服务商返回的为准」。
## 13. 运行记录
每次运行写一份记录(`app/record/RunStore.kt`):`files/runs/<时间>-<编号>/` 下的 `run.json`、`events.jsonl` 与画面截图,保留最近 30 次。事件类型见 `core/record/RunEvent.kt`(模型回复与用量、工具结果、观察、活动、确认、接管、来源、压缩、结局判定等);写入前由 `Redactor` 遮掉密码框输入和像密钥的字符串。界面上「查看过程」打开时间线(`app/ui/RunTimelineScreen.kt`):顶部是目标、标准、结局、用时,模型调用次数等细节收在「更多详情」里,下面用竖线步骤(`components/StepLine.kt`)逐步列出,点有画面的步骤看当时的画面。
**每件事一份小结**(`core/record/RunMetrics.kt`):`RunMetrics.of(events)` 是纯函数,只看运行记录里的事件;时间都从开始事件算起(之前载入知识、连手机的准备不计)。
- 总时长到结局事件(没结束的到最后一个事件,结局记 unfinished);结局另带失败类别(结局事件的 `kind`,即 `FailKind` 的小写名)和交出回答后判的结局。办成 = 报了成功(含没能核实的),且没被判成只办了一部分或办不到。
- **第一次反馈**:开始到用户第一次看到这次运行的实际进展——最早一条真执行了的模型回复到手的时刻(回复事件的 `repliedAtMs`;带调用的回复执行完才记,所以另记到手时刻),这时界面显示它的进度说明、要做的事或回答;或最早一条活动(回放不经模型时第一步做完)。改派掉的轻量模型回复不算;回答逐段显示时第一段字更早,没有记录,不算。
- **和模型来回**:模型回复事件数(主循环的请求,含改派掉的轻量模型回复);token 是这些回复的用量之和,没报用量的另计次数。旁路调用(意图判断、核查、看图、压缩)和失败重试不发回复事件:运行把 `RunUsageTally` 放进自己的协程上下文,`metered` 套上的模型每次请求顺手加一笔,收尾时记成一条 `RunEvent.ModelCalls`(全部请求、没报用量的次数和 token)。交出回答后的结局判定、记忆提取在这个上下文之外,只进 Token 用量页。
- **时间去向**:模型思考是各回复的请求耗时之和(重试前失败的请求和退避另记);读屏 / 操作是顶层工具调用(模型调的、回放的)的执行耗时,按唯一执行入口给活动定的种类分(工具声明的风险是只读的算读,其余算操作),没有新加计时点。一批动作、页面脚本的外层调用耗时已含子步:子步里的读计入读,余下算操作,不重复计;等待、问用户、请确认、交给用户这类控制调用不算读也不算操作。其他 = 总时长减这三项:等用户、重试退避、控制调用、核查和其余开销。
- 运行记录的写线程边写边算(`RunMetrics.Builder`,只留每个工具结果的几个数字),关上时把小结交给 `RunMetricsStore`:`files/run-metrics.jsonl` 一行一份,只留最近 500 份,不随 30 次的运行记录清理;判了结局再补进去。读不出的行跳过,写是整份换上。主循环上没有多一次读写。
- 界面:「查看过程」顶上一行「共 2 分 13 秒 · 和模型来回 14 次 · 8.6 万 Token · 第一次反馈 3.2 秒」,点开是模型思考 / 读屏 / 操作 / 其他的用时与占比、输入 / 输出 / 缓存命中的 token、步数(`app/ui/RunMetricsUi.kt`;token 的写法和 Token 用量页同一个)。开发者选项 ›「运行统计」按最近 10 / 30 / 100 次或全部算办成率、用时与第一次反馈的中位数、平均来回次数与 token、时间去向(`RunStats`),能把这几次的小结复制或分享成 JSON。
- 评测:每题的 `Observed` 带上 `RunMetrics`,`EvalReport` 的汇总多一节「运行小结」(汇总和逐题一行),结果目录另写 `metrics.json`(`EvalReport.metricsJson`)。
## 14. 提示词
模型看到的固定文字都在 `core/src/main/resources/prompts/`,分层与规则见那里的 `README.md`:`system/`(身份与通用准则)、`capabilities/`(每种能力一段,只在那组工具展开后拼进去)、`tools/`(每个工具的说明)、`entries/`(入口工具在那一组收着时的短说明)、`feedback/`(工具结果与主循环的反馈)、`roles/`(核查、结局判定、记忆提取、技能草稿等内部角色)。系统提示词由 `SystemPrompt`(`core/agent/SystemPrompt.kt`)在任务开始时拼好,只在展开一组工具时重拼(段落按固定的先后);全部文字有金样测试,收着、只开手机、只开网页、全开四种情形的全貌和大小在 `prompt-surface/layers/`。
## 15. 界面与可见性
- **样式**:颜色(浅色、深色两套,跟随系统)、字号、尺寸、圆角、动效时长都是令牌,在 `app/ui/theme/`(`Theme.kt` 的 `MikaTheme`、`Colors.kt`、`Tokens.kt`);线条图标在 `app/ui/theme/Icons.kt`;分组卡片、列表行、开关、按钮、顶栏、玻璃输入栏等通用组件在 `app/ui/components/`。页面只从这里取色和取组件,不写死色值。玻璃输入栏在安卓 12 起做背景模糊,更早的系统用不透明底。
- **导航**(`app/ui/MainActivity.kt`、`app/ui/Navigation.kt`):对话页是根页面,其余页面(设置、模型、记忆与技能、定时任务、浏览器、运行过程、来源、手机操作向导、上手的欢迎与配置模型)压进页面栈 `NavStack`,返回键和页面上的返回按钮都退回上一页(从侧栏进的回到打开着的侧栏,从设置进的回设置)。各页面只通过回调进出。
- **侧栏**(`app/ui/ChatDrawer.kt`):对话页左上角菜单从左边滑出。顶部是标志、Mika 和就绪状态;入口有新对话、定时任务、记忆与技能、浏览器、设置;下面是「进行中」(当前对话和别的有任务在跑的对话,并行时可切过去)、「置顶」(置顶了的对话,最近置顶的在前)和「最近」的对话(`drawerSections`);每个对话只列在一段里:置顶的对话有任务在跑时列在进行中(也带图钉),跑完回到置顶。别的对话有任务在跑时菜单按钮上有小圆点。长按一条存过的对话(振一下;无障碍里是「对话操作」)在原地弹出菜单(`app/ui/ConversationMenu.kt`):整屏变暗,安卓 12 起后面的对话页和侧栏还模糊(更早的系统只变暗);那一行在原来的位置「抬起」成一张略放大、带阴影的白卡(内容和侧栏里那一行一样),下面接一张窄一点、左边对齐的白卡,依次是「置顶」(置顶了的是「取消置顶」)、「改名」、红字「删除」,文字在左、图标在右,项与项之间细线隔开。下面放不下(行靠近底部、键盘弹着)就放到那一行上面,两边都放不下时把那一行往上挪、菜单接在下面,都不越出状态栏、导航栏和键盘(`placeMenu`)。点外面、按返回收起(倒着放一遍出现的动画);关了动画时出现、收起都直接到位,不缩放。菜单开着时后面的内容不给无障碍读,菜单带面板标题「对话操作」和「收起」操作,每项可聚焦、读得出名字。菜单画在对话页同一层里、盖住整个屏幕,位置按长按那一行在窗口里的坐标摆。还没存进列表的新对话不响应长按。**改名**弹出输入框,里面是现在的标题(整段选中),去掉两头空白后保存,空的存不了;写进 `.title`(`ConversationStore.rename`,不管有没有标题都换)。起好的标题只在还没有标题时写(`setTitle`),所以后台起标题晚到也盖不掉用户改的名字;侧栏、悬浮胶囊卡片顶上「接着「…」聊」都随 `ConversationStore.changes` 换成新名字。**置顶**写一个 `.pin`(置顶的时间,`ConversationStore.setPinned`),取消置顶删掉它;对话列表带上置顶时间(`ConversationSummary.pinnedAt`),不改对话的改动时间,取消后回到「最近」里原来的位置;置顶的标题前有个小图钉。删掉的对话不再改名、置顶。**删除**弹出确认「删除这个对话?」,写明对话标题;它有任务在跑或排队时写明会停掉,绑着定时任务时写明几条会一起删除;右边红字「删除」、左边「取消」(和别处删东西的确认同一个样子)。换不了对话时(任务进行中、没开并行)点不开,长按照样能用(说明写在第一段存着的对话上面)。确认后侧栏当场不再列它(`ChatViewModel.deleted`),删的是屏上这个对话就换成新的空对话、不再跟它的任务;其余在后台由 `ConversationDeleter`(`app/chat/ConversationDeleter.kt`)做:先删 `trigger.threadId` 是它的定时任务(不让它们再开始新任务),再经 `TaskService.cancel` 停掉它所有在跑、排队的任务并等它收尾(最多 15 秒,收尾时存进的「已停止」那一轮也在删的范围里),然后删它的工作区(附件 `inbox/`、用户发来的屏幕 `screens/`、任务写的文件;按句柄删、不跟进链接,拿着树锁的共享许可、和重置互斥),最后 `ConversationStore.delete` 删对话文件、`.title`、`.pin` 和写到一半的临时文件、挪到一边的损坏文件。之后才到的补写(后台起好的标题、收尾后补的结局和交代)不把它写回来;有人再往这个编号里存进一轮(悬浮胶囊接着在那个对话里说)就当重新开始,侧栏重新列出。运行记录(运行过程、运行统计)是全局的,不删;导出到下载目录的文件是用户的,不删。
- **对话页**(`app/ui/ChatScreen.kt`,文字在 `app/ui/ChatCopy.kt`):顶栏是「Mika」和此刻在做什么(正在操作哪个 App / 等你处理),右上角新对话。空对话是按时段的问候和一列示例,点了直接发出。任务进行中是一行状态(转圈、当前进展、用时)加一条竖线步骤,在后台操作手机时下面一行小字;要你处理(接管、提问、确认、暂停、锁屏、被拦下)时进度停在当前步(橙点、「已暂停」),下面一张白卡写清要你做什么和按钮。完成后过程收成一行「做了 N 步 · 用时 · 查看过程」(进时间线),回答按 Markdown 排在很淡的灰底上(左下尾角,和用户的蓝色气泡对称),下面是「来源」胶囊(点开看原文和画面)和文件卡片。回答的文字(完成的回答、停止后补的交代)和用户自己的话(蓝色气泡里的字)都包在 `SelectionContainer` 里:长按出系统的选择把手和复制条,回答里的链接照旧点开;只包文字,来源、查看过程、文件卡片、照片格子这些入口不在选区里,照常点。正在长出来的回答不能选(字一直在变)。选择把手在单独的弹出窗口里,拖把手不会带出侧栏;长按之后接着拖选的移动由选区消费,侧栏的横划和列表的竖滚都不跟着动,没长按的横划、竖滚照旧。悬浮胶囊的卡片不做选中:它在无障碍浮层窗口里,平时不抢焦点,选择把手和系统复制条在那里出不来或不可靠。输入栏是玻璃胶囊,任务进行中照常可以输入,作为插话(输入框灰字说明)。屏上的轮次以 `ConversationStore` 存下的为准:它写过哪个对话就在 `ConversationStore.changes` 里发出那个对话的编号,不管是谁写的(这里、悬浮胶囊、定时任务);`ChatViewModel` 收到屏上这个对话的就重读,按运行编号和这里跟过的那几轮合并(用这里那份,带步骤和用时,不重复),正在跟的那一问一答留在最后;侧栏开着时也随它重读对话列表。对话页重新看得见时(回到前台、从别的页面回来,`ChatViewModel.refresh`)再补一遍,并接上这个对话里别处开始、这里没在跟的任务(原话用任务自己的,别处插的话也挂上);不用等回到前台,屏上这个对话里别处开始的任务一有状态就接上,在跑时就有那句话和步骤。服务先把任务登记进任务列表(`publishRuns`)再显示它(`TaskRun.show`),界面收到状态时原话已经取得到;接上时还没取到原话的,任务列表一更新就补上。并行开关关着时界面也按状态带的对话编号认,别的对话的任务不接到屏上这个对话里;服务换去显示别的对话刚开始的任务时,这里正在跟的任务让服务显示回来。
- **对话页的附件**(`app/ui/ChatAttachments.kt`、`AttachSources.kt`,纯逻辑在 `AttachmentMessage.kt`):输入栏左边的「+」点开的是输入栏下面的面板,占键盘那块位置、和键盘一样高(没见过键盘时用默认高度),开面板收键盘,再点「+」(转成 ×)或点输入框就收起、换成键盘,两者互换时输入栏不上下跳;返回先收面板。面板上面一排相机(系统相机拍照,照片先写进缓存里的 `camera/`,不要相机权限)、相册(系统照片选择器,不要权限)、文件(系统文件选择器),下面四列「最近的照片」(媒体库里新加的在前,缩略图在后台按格子大小读)。读照片的权限第一次打开面板时问一次(安卓 13 起读照片,14 起可以只给部分照片,12 及以前读外部存储);没给时那块是一句说明和「允许访问照片」(选过不再询问时去 App 信息),只给了部分时列出给了的、旁边能改范围。选中的照片带序号,照片和文件一起最多 9 个,挂在输入栏上方一排(照片缩略图、文件小卡片,× 去掉);有字或有附件就能发,可以只发附件。发送时才把它们复制进对话工作区的 `/workspace/inbox/`(`Attachments.importInto`,和以前一样每个不超过 200 MB),消息末尾每个一行「附件:<路径>(<大小>)」交给任务——模型拿到的还是这几行路径,图片照旧由沙箱的读文件工具交给看图;附件要沙箱,设备不支持沙箱时没有「+」。对话页按这几行把用户的消息画成照片格子(1 张保持比例的一大张,2 张并排,3 张起三列)和文件卡片(按扩展名配色的类型角标、名字、大小),写的话另起一个气泡,路径那几行不显示;旧对话里的消息同样这么画。点照片或卡片时从工作区(经文件闸门)复制一份到缓存里的 `viewing/`,交给文件查看页(图片可缩放),看不了的类型交给别的 App;这两个缓存目录由 `AttachFilesProvider` 给出 content:// 地址,不对外导出、用到时临时授权,工作区本身不开放。对话标题、侧栏里只发了附件的消息叫「照片」「3 张照片」「合同.pdf」之类(`ChatCopy.plainTitle`)。悬浮胶囊的卡片不变。
- **相机声明与刷新率**(`AndroidManifest.xml`):拍照附件走系统相机,manifest 里声明用到相机、但不是必需(`required="false"`),没有相机的设备照常安装。有的系统按 APK 特征给应用分类,不声明时 Mika 可能被当成游戏、限制刷新率,所以这条声明要留着,`ManifestProvidersTest` 守着它。
- **文件查看页**(`app/ui/viewer/FileViewerActivity.kt`,界面在 `FileViewerScreen.kt`):对话里的文件卡片点开后在这里看,单独一个 Activity,别处也能用 `FileViewer.open` 打开。类型先看内容提供方给的 MIME,认不出再看扩展名(`ViewerKinds`,`app/ui/viewer/ViewerKind.kt`):网页用一个新建的 WebView(脚本打开、可缩放,不经过网页能力那套浏览器);Markdown 用对话里同一个 `MarkdownText`;PDF 用系统的 `PdfRenderer` 按屏宽逐页画,只画看得见的页,可双指缩放,底部显示页码;图片按比例铺满、可缩放拖动;文本类等宽显示、可选中。文本和 Markdown 最多读前 1 MB,超出时顶上一句「文件较大,只显示前面一部分」。顶栏右边是「分享」和「用其他应用打开」;读不出来时说一句并给「用其他应用打开」。其他类型不进这页,直接交给系统的选择面板。
- **小画面**(`app/ui/AgentPip.kt`):对话页上一个可拖动的悬浮小窗,平时停在右下角、输入栏上方,实时显示 Mika 正在操作的虚拟屏或网页画面(只看不操作);绿色细边表示在干活,橙色呼吸光表示需要你。它是 App 内的浮层,不是系统画中画。
- **悬浮胶囊**(`app/capsule/`):用户在别的 App 里时,一颗 Mika 图标浮在屏幕右上角、状态栏下面,说后台此刻最要紧的那件事;平时只是一颗(约 40dp),有事要告诉你时才展开成一行字的胶囊。浮层借无障碍服务画(`app/cap/phone/a11y/OverlayHost.kt`:无障碍浮层窗口,不要「显示在其他应用上层」权限;在 Activity 之外给 Compose 配上生命周期、状态保存与 ViewModel 的宿主;平时不抢焦点,要打字时可切成能接收键盘;服务断开时 `ScreenAccessibility` 把它加的窗口一起拆掉;加不上就返回空,不报错)。显示什么由纯逻辑 `CapsuleModel` 定:开关关着、无障碍服务不在或浮层画不出来、Mika 自己的界面在前台(`AppForeground` 数看得见的 Activity)、屏幕关着或锁着(`DeviceStateWatcher`)时不显示;否则按优先级只说一件——你在处理(App 已挪到主屏,顶部正中一条「你来处理 · 不做了 · 处理好了」,两个按钮就是取消任务和交还)、需要你接管 / 同意 / 回答、等条件、干活中、办好了 / 没办成 / 已停止。一颗上的状态:干活中外圈一段蓝弧绕着转(关了动画时是一整圈不动),等条件灰圈,要你处理整颗变橙、外圈一呼一吸,好几件事时角上写件数,办好了绿勾、没办成和已停止灰色角标,空闲(「一直显示」没任务)什么都没有;干活中换了一步不展开。展开成胶囊只有两种时候:有要你处理的、而你还没在卡片里看过它(一直停着,不缩、不淡,直到打开卡片或那件事不再要你;打开卡片时看到的那几件——同一件事、同一个等待——记为看过,收起后回到橙色的一颗,来了新的或另一个等待才再展开),和刚办完(「办好了 · 点开看结果」等,展开 4 秒后缩回一颗带着结果角标,一共停 10 秒后消失,「一直显示」时回到图标;在卡片里看过的直接是一颗)。好几件事时胶囊上写「共 N 件」,有要你处理的写「1 件要你处理 · 共 2 件」;文字先用 `Running.narration`,再用 `reason`,回放和批量执行写成「照着路线做 3/7」(读屏时一颗的说法是 `CapsuleCopy.describe`,如「Mika 在干活:……,点开看详情」)。同一件事同一个状态里每句至少停 1.5 秒(这期间只留最后一句),胶囊只变宽不变窄,换了状态、展开或缩回才重新定宽;一行最宽到屏幕的 60%。一颗和胶囊是同一块底,宽度在两者之间慢慢变,图标贴着停靠的那一边不动(关了动画时直接换)。`CapsuleContent` 是不带状态的胶囊界面,`CapsuleController` 把任务状态、开关、前台、屏幕和服务接到模型上,按结果加、挪、拆窗口。点一颗或胶囊都展开卡片(`CapsuleCardContent.kt`,不带状态;内容由纯逻辑 `CapsuleCards` 定,见 `app/capsule/CapsuleCardModel.kt`):卡片只说一件事,没有列表,也不在卡片里切换:先是刚在卡片里发了话的那个对话的任务(好看到回答),否则按要你处理(接管、同意、回答)、进行中、刚结束的先后取第一件;同时还有别的事,到 Mika 的对话页看。选出的那件按此刻的状态(不按经过)选卡片:还没结束、此刻在操作 App 或网页(有自己的虚拟屏 `TaskRun.displayId`,或正在用内置浏览器 `TaskRun.webOpen`)的是任务卡片,别的——纯聊天、已经结束的(包括操作过 App、办完回了话的)、空闲时——都是对话卡片(`CapsuleCards.taskCard`)。任务卡片里干活中是最近三步(和对话页的步骤同一套推法,`ChatViewModel.reduce`)、用时;任务还在进行(干活中、Mika 问你、等条件)时,停止和对话页输入栏一样是输入框右边的黑色停止键;要你接管是为什么(付款时列出商品、金额、地址)、我来处理(带这次等待的编号,和通知上的一样)、停止,排在别的接管后面时写「先处理完另一件的接管,再处理这件」、不给我来处理,排在最前、后面还有的写「处理好这件后,还有 N 件等你」(先后读的是几个任务共用的 `TakeoverQueue`,排队一变就重读;同样要紧的几件里先说队里排在前面的,前面那件交还后下一件马上成为胶囊上的「需要你」);要你同意和对话页的确认卡片同一套字和申报编号,同意 / 不同意,另有「这个任务都允许」「以后都允许」,底线操作只给「只同意这一次」;Mika 问你给了选项就列成按钮(作为回答);等条件说为什么,能继续时给继续;底下是「回到 Mika 看完整过程」。对话卡片是那个对话的来回(`app/capsule/CapsuleThread.kt` 的 `CapsuleThreads` 排,`LazyColumn` 画):和对话页同一份记录(`ConversationStore` 记下的轮次,接上还没记进去的、这个对话里此刻的任务,按运行记录编号去重),你说的话是右边的小气泡,Mika 的回答是去掉标记的全文(`CapsuleCards.answerText`),旧的在上;操作过 App 或网页的那一轮(从那一轮的运行记录里读它的活动对象,记录已清掉的当作纯聊天)只收成一行「✓ 购物 · 办好了」「12306 · 没办成」再接回答,不放步骤、画面和来源;纯聊天在回答时字跟着长出来,输入框右边是停止键,要你回答、同意时那一问在来回里、按钮固定在下面。先排最近 10 轮,滚到顶再往前排 10 轮(每条按运行记录编号定 key,列表停在原处);打开时最新那句你说的话停在可见区域顶上,往下长的字只在你本来就在底部时跟着往下(同对话页)。底下是输入框和「回到 Mika」。按钮都走 `TaskService` 现有的动作(取消、继续、接管、回答、同意、不同意),带着那件事的任务编号;「回到 Mika」和点通知同一个入口 `TaskService.openAppIntent`。点卡片外面、收起按钮或返回收回成一颗(还有没看过的要你处理时是胶囊);展开着时模型停表,用户正看着的结果不会被收走,收起后接着走剩下的时间;App 交到你手上时卡片收起,换成顶部那一条。一颗和卡片各在一个浮层窗口里、各待各的位置(卡片那个收起时是空的,没有大小):展开时卡片在自己的窗口里从那一颗的方位缩放、淡入(约 240 毫秒;先等卡片排好版再起步,这之前一颗还在原处),一颗那个窗口随之空出来;收起时卡片缩回那一颗的方位、淡掉,一颗同时在边上冒出来(都在 `app/capsule/CapsuleCardContent.kt` 的同一段动效里,只动图层,关了动画时直接到位)。不让一个窗口在两处之间来回挪,因为安卓会把窗口的单纯挪动做成滑过去的动画;也不逐帧改窗口大小(每变一次窗口都要重排、换画布,一卡一卡)。一颗和胶囊之间的宽度也只在画的时候变:布局一下到两者里宽的那个,画完才缩。干活中、要你接管、等条件时左边放虚拟屏小画面(那件事自己的 `TaskRun.frames`:手机是虚拟屏的帧,网页是内置浏览器的帧,和应用内画中画同一个来源;没有画面的任务不放),只在卡片展开、看着那件事时才收,每秒至多 5 张,和上一张一样的不推(`previewFrames`,在 `app/capsule/CapsulePreview.kt`);点一下放大,再点一下或点外面回来。胶囊可以拖(`OverlayHost` 的窗口按整块屏幕摆、只有内容那么大,拖动在窗口根上按屏幕坐标算,移过触摸阈值才算拖,按着的按钮随之取消,不会当成点按),松手贴到最近的左边或右边(离边 12dp,避开状态栏、刘海和底部手势条,范围由 `OverlayHost.area` 读系统栏);停在哪边、高度占可摆范围的比例记在 `FeatureFlags`,下次和转屏后按比例摆回;「你来处理」那一条在顶部正中,只能上下拖。卡片宽度不变、左右居中。对话卡片固定高、不随内容变:屏幕高度的 58%,输入法弹出时收到它上面(中间那段变矮,输入框照样看得见);任务卡片按内容高、下面不留空,中间那段取左边小画面(按画面的宽高比)和右边文字里高的那个,没有画面时就是右边文字那么高,最高和对话卡片一样,再长的文字在右边那段里滚。两种卡片中间放不下都在里面滚,按钮和输入框固定在下面。卡片的位置按上边记:上边在可摆范围里的比例(范围按最高的卡片算,高矮不同的卡片同一个比例上边对齐),换卡片(任务卡片和对话卡片互换)或内容变高变矮时上边不动,一下到位(窗口跟着内容大小只变一次,不逐帧变),往下放不下(手势条、输入法)时往上挪到刚好放下。卡片按着标题栏上下拖(`OverlayWindow.draggable` 给一块把手,只有按在里面才拖,中间上下滑是滚动;同样移过触摸阈值才算拖,不会点到收起按钮),松手夹在状态栏、刘海和手势条之内,上边的比例另记一份在 `FeatureFlags`(以前按固定高卡片记的比例正好是它的上边比例,照样读),下次展开和转屏后按它摆回。算法是纯函数 `CapsuleDocking` 和 `CardDocking`(在 `app/capsule/CapsuleDock.kt`)。读屏时胶囊上多一个「移到屏幕另一边」代替拖动,卡片的按钮、小画面、收起和返回都有说法。卡片底部有输入框(要你接管、要你同意时没有):在跑、等条件时是「补充或改要求…」,交给那件事(和对话页任务进行中插话同一条路,`TaskService.start` 带上这句话的编号);Mika 问你时是「回答 Mika…」(选项按钮照留);办完后是「接着说…」,在同一个对话里接着说;没有任务时(开关是「一直显示」)点 Mika 图标是空闲的对话卡片:顶上「接着「<最近对话标题>」聊 · 新对话」,中间是那段对话的来回(新对话时是空的),输入框「想让 Mika 做什么?」。后两种和在对话页里发一样开一个任务(记进那个对话;别的任务在跑时排上,不丢),之后卡片跟着那个对话的任务,回答在对话卡片里长出来;那件事打开了 App 就换成任务卡片。输入框平时只是一行提示,点了才让浮层能接收键盘(`OverlayWindow.setFocusable`),发出、收起、点外面、按返回时马上还回去;输入法弹出时让开它(输入法窗口的位置由 `OverlayHost.imeTop` 从无障碍的窗口列表里读,窗口一变就重读,不论输入法是对着哪个 App 弹的):卡片、一颗和展开的胶囊都挪到它上面,输入法收起后回原处。不碰它 3 秒,一颗淡到约 40%(外圈和角标保持实色),碰到、拖动或状态、件数变了马上恢复;展开的胶囊、要你处理、「你来处理」那一条和卡片不淡(规则是纯逻辑 `CapsuleFade`)。转屏时按记下的边和高度比例重新摆;全屏播放时不特殊处理。开关在设置 ›「悬浮胶囊」(在 `app/ui/SettingsSubPages.kt`):关闭 / 有任务时显示 / 一直显示(默认,随时能双击胶囊发这一屏),存在 `FeatureFlags`;没选过的(含旧版本升级上来的)按默认。
- **此刻的屏幕**(双击胶囊):双击一颗或胶囊,把用户此刻在主屏上看着的那一屏发给单击会展开的那张卡片里的对话;之后和在卡片里发一句话完全一样(在跑时插话、Mika 问你时作为回答、办完接着说、空闲时接着最近的对话或开新对话,同一个 `CapsuleController.submit`),然后展开卡片看回答。单击要等安卓的双击时限(`ViewConfiguration.getDoubleTapTimeout`)过去才展开,单击双击的判断是纯逻辑 `DoubleTap`;读屏软件里另有「把此刻的屏幕发给 Mika」。那张卡片没有输入框(要你接管、同意)时没有地方可发,双击就和单击一样。显不显示照旧跟着悬浮胶囊的开关,没有另外的开关;Mika 自己在前台、锁屏时胶囊本来就不在。卡片输入框左边有截屏按钮:截下来挂在输入框上面(缩略图、「你此刻的屏幕」、App · 时刻、× 不要),可以补一句再发,只发它就和双击一样。
截屏:浮层窗口不拆,只把里面的内容藏起来(`OverlayWindow.hiddenWhile`),等藏起来的那一帧画好交给系统(帧提交回调)、再过两个刷新(合成器换上它)才截;卡片的输入框开着时先收起、等输入法下去。截图用无障碍服务的 `takeScreenshot` 截主屏(`ScreenAccessibility.takeMainScreenshot`,太快时等一秒再截),截完马上把浮层显示回来、一颗上闪一下,同一时刻读主屏上 App 窗口的无障碍树(状态栏、导航栏、输入法、浮层不算)。整理在后台(`MainScreenCapture.build`):屏上的字先用树里有名字的元素,树不够用(和任务读屏同一套 `TreeAdequacy`)时认画面上的字(`PhoneOcr`),只补树里没有的、去掉认错的行和状态栏、导航条那几条里的字;只是把框在里面的几段字连起来的元素(一行设置项的整行)不单列,留里面那几段;每行带中心点(0–999,树里元素的像素中心也换算过来),从上到下、同一行从左到右(`core/userscreen/UserScreenText.kt`)。防截屏的页面(截图报安全窗口错误,或截出来几乎全黑而屏上有 App 窗口)不留画面,只记下是哪个 App、截不到。前台是 Mika 自己时不截。
存放:跟着对话放在那个对话的工作区里(`/workspace/screens/<编号>.jpg` 和 `.json`,`app/chat/UserScreenFiles.kt`,经工作区文件闸门读写、写时拿着树锁的许可),和附件同一处,工作区删掉时一起没了;沙箱里的命令也读得到这张图。消息本身是普通的一句话:单独一行 `[[screen:<编号>]]`,其余是附言(`core/userscreen/UserScreenMessage.kt`),照常存进对话、作为插话或回答,从相册或文件选的图片照旧是附件那一行,两者不混。对话页和卡片按它画成截图气泡(`app/ui/ScreenMessage.kt`:缩略图、「你此刻的屏幕 · App · 时刻」,附言另起一个气泡;不让截图的是一行「没截到」),对话标题、侧栏、通知里称它为附言或「你此刻的屏幕」(`ScreenCopy.plain`)。
给模型(`core/agent/UserScreenTurns.kt`,文字在 `UserScreenPrompt` 和 `feedback/loop.md` 的 `user_screen.*`):这句话换成「用户刚截下他正在看的屏幕(App,时刻),没有附言 / 附言:…」(不让截图时照实说没截到),主模型看得到图时截图接在这句话里;紧跟一段 `user_screen` 上下文(不进预算、压缩时保留):编号、App 和包名、时刻、屏上的字(套不可信标记),没有附言时再带上按屏幕猜意图的规矩——只是回答、读、查就直接做;要往别处写或改东西(记一笔、发出、发布、预订、付款、删除、改设置)先用 ask_user 一句话问一次、给选项(卡片里是按钮),答应了再做;不知道用户用哪个 App 做这类事一并问,能记忆时记下来。有附言的照常办,不另加规则;不可逆操作的确认照旧。只发屏幕没说话时不问意图判断(没有话可判)。之前几轮里的屏幕只剩一行(App、时刻、屏上开头几个字和编号),要再看用 `view_user_screen`:按编号给 App、时刻和字,主模型看得到图时附截图,看不到图而配了看图模型时带着问题问看图模型那张存下的截图(角色提示词 `user_screen`);这次的话或之前几轮里带着屏幕时才提供。屏上的字记进证据账本(回答来源);判结局、提取记忆读换好的那句;带屏幕的话不记经验路线。
- **通知**:任务进行、需要接管、需要回答、结果都有通知;接管按钮在通知上。「需要你处理」和「任务结果」两个频道建成高重要程度(弹出),「任务进度」保持安静;胶囊正显示在亮着的屏幕上时,「需要你」照发但安静地进通知栏(用系统的分组提醒规则:这条放进一个没有摘要的组、只让摘要提醒,系统就不响也不弹横幅,和系统自己的静音通知同一个做法;不换频道、不新建频道),锁屏、胶囊关着或画不出来时照旧弹出;结果频道换过 id(`task_result_v2`,旧的启动时删除),各处都经 `TaskService.ensureChannels` 建频道。状态监控检查这两个频道有没有被调低,调低了就提示,修复按钮直接打开该频道的系统设置页,打不开退回应用通知总页;手机操作上手时通知允许后也检查一次。设置 ›「通知」页(在 `app/ui/SettingsSubPages.kt`)只看状态、去系统里改,不在 Mika 里放通知开关:顶上是通知总开关(没允许时「去打开」,安卓 13 起先请求权限,否则打开应用通知页),下面「需要你处理」「任务结果」「任务进度」三个频道各一行,写明什么时候发和现在是「会弹出」「只进通知栏」还是「已关闭」,点一行打开该频道的系统设置页(与上面同一套跳转与降级)。状态判断是纯函数 `NotificationOverview.of`(`app/settings/NotificationOverview.kt`):按频道重要程度,`IMPORTANCE_NONE` 为已关闭、不低于 `IMPORTANCE_HIGH` 为会弹出、其余只进通知栏;「需要你处理」「任务结果」不是会弹出就标出(进度安静或关闭不算异常),总开关关着时三行都是已关闭;首页「基本」里的「通知」一行右侧显示总体状态(正常 / 有 N 项不会弹出 / 已关闭),手机操作页的「通知」一行也进这一页。频道重要程度由 `SystemStatusMonitor` 读进 `SystemStatus`,从系统设置回来时随 `refresh` 更新。
- **其他页面**:设置(`app/ui/SettingsScreen.kt`):顶上一张总状态卡(沿用 `Prerequisites.check`,一切正常是「一切就绪」,有问题变成提示并带修复按钮),下面四组——基本(模型入口与当前主模型、Token 用量、手机操作、干活时、悬浮胶囊、通知)、隐私与数据(记忆与技能、网站登录)、高级(文件与计算环境、记住的操作路线、恢复与修复、开发者选项)、关于 Mika(有新版时写「x 可更新」带红点)。各项点进去是子页面(`app/ui/SettingsSubPages.kt` 里的手机操作、干活时、悬浮胶囊、文件与计算环境、记住的操作路线、恢复与修复,以及 `DeveloperOptionsScreen`;由设置页自己切换;模型页和配置模型页走 `NavStack`);「恢复与修复」页是手机操作的三个授权开关(默认打开,拨动直接执行、不再确认,说明写在开关下面;还没配好手机操作时显示打开并提示先完成设置),不支持的系统(低于安卓 13)和沙箱不支持的设备相应的入口不显示。工程开关(`app/settings/FeatureFlags.kt`)在开发者选项里,分「速度」(先读元素必要时看图、操作后带回屏幕变化、有把握时连做几步、直接打开要去的页面、简单步骤交给轻量模型)和「实验」(照着上次的做法做、不同对话同时进行、长任务的摘要分条写)两组,开发者选项只在调试版显示;首次安装或升级后打开一次「先读元素,必要时看图」「操作后带回屏幕变化」「有把握时连做几步」(之后用户手动关掉就保持关闭),其余默认关;发布版同样生效,只是设置里看不到这些开关。另有记忆与技能、计划、手机助手设置向导。记忆与技能页(`MemoryScreen`、`SkillsSection`)分「记忆」(按事实、偏好、做法分组,点一行升起面板:改一改、置顶、删除)、「技能」(开关即启用与停用)、「待确认」(待批准的做法、新技能草稿、App 知识草稿)三段,开关与全部清除放在最后;来源页(`SourceScreen`)每条来源有截图时只显示截图(点开全屏放大),没有截图的(后台读的网页正文、文件、命令输出等)显示原文摘录,与许可证页(`LicensesScreen`)和它们用同一套顶栏、分组与行。
- **关于与检查更新**:关于页(`app/ui/AboutScreen.kt`,文字在 `AboutCopy`)从设置首页或侧栏最底下「关于 Mika」进(侧栏底部一行「设置」靠左、「关于 Mika」靠右,走 `NavStack` 的 `Route.About`)。顶上版本号、Beta 标记(`BuildConfig.BETA`)和构建编号(`BuildConfig.GIT_HASH`,编译时取当前提交的短哈希,没有 git 时为 dev);下面检查更新与更新记录,反馈问题(去 GitHub 提问题,问题模板预先填好机型、安卓版本、Mika 版本三行;加作者微信;在 X 上找作者)、加入内测群(作者微信二维码 `res/drawable-nodpi/author_wechat_qr.jpg`,可存进相册 Pictures/Mika;二维码下面「已经加过作者微信?」一个「让 Mika 帮我发消息入群」按钮:模型和手机操作都就绪时(`AboutCopy.groupTaskGap`,按 `Prerequisites.check` 判断)在新对话里开始一件任务——按微信号 `MikaLinks.AUTHOR_WECHAT_ID` 找作者发入群请求,找不到就只提醒先扫码加好友,原话是 `MikaLinks.GROUP_TASK`——再回到对话页看它跑;没就绪时提示差什么并去对应设置页)、关注作者,开源仓库与使用说明;数据说明;页脚「基于 Apache-2.0 开源许可发布」点进许可证页(`Route.License`:许可证原文 `assets/licenses/Apache-2.0.txt`,下面列第三方组件)。对外地址都在 `app/about/MikaLinks.kt`。检查更新(`app/about/UpdateChecker.kt`)没有自己的服务器,只问 GitHub 的 releases/latest 接口(不登录、短超时),连不上、限流、读不懂都算没查到;每次打开 App(`MainActivity.onStart`)都悄悄查一次,只挡住连着来的(离上次自动检查不到两分钟的不查,`UpdateSchedule.MIN_GAP_MS`;开查前先记下时间,同时进来的不会查两遍),失败不提示、没有横幅,手动点「检查更新」才提示结果。查到的版本存在 `files/update-v1.json`,比当前新时侧栏、设置首页、关于页带红点;更新说明里单独一行「国内备用下载: <地址>」(或 `[国内备用下载](<地址>)`)会变成「国内备用下载」按钮。只打开浏览器去下载,不在 App 里安装。
- **诊断信息**:用户主动点「复制诊断信息」(引导页底部一直有、失败时出错的那一步里也有;关于页 › 反馈问题的第四项)才收集,先在底部面板(`app/ui/DiagnosticsSheet.kt`)里整段给用户看,点「复制」才进剪贴板、点「分享」才交给系统分享面板,什么都不自动发出。收集(`app/about/DiagnosticsCollector.kt`)只读现成的状态:系统状态、内置助手的状态和最近一次配对或连接失败的原文(`BuiltinShellEngine.lastError`)、找连接端口时各来源给了几个候选、扫本机时几个端口开着几个像 adbd、有没有用户填的端口(`AdbConnectPort` 报给 `BuiltinShellManager.lastConnectCandidates`,只有个数,不写地址和端口号)、自动恢复那几项、模型配置、运行小结、检查更新;引导页的步骤用引导页自己的输入(`setupInputs`,含用户确认过的步骤)交给同一个 `PhoneSetupFlow` 算,不另算一遍。写成文字(`app/about/Diagnostics.kt`,纯函数)时分段顺序固定、英文键:App、Device、Phone control、Recovery、Models、Recent runs、Update;服务商只写接口类型和主机名,运行只写相对时间、结局、失败类别、步数和模型,不写 Key、登录、任务目标、回答和屏幕内容;最后整段过一遍脱敏(IP 连端口、端口号、邮箱、像凭据的「名=值」,再交给 `Redactor`)。GitHub 问题模板最后一段只提示用户可以去哪里复制后粘贴,不自动带上。
- **首次上手**:欢迎(`app/ui/WelcomeScreen.kt`)→ 配置模型 → 手机操作(`PhoneSetupScreen`,编号清单,可跳过;连接成功后清单保留、全部打勾,顶上一行「已经设置好了」,底部主按钮「开始使用」,点了才进对话,从设置进来的是「完成」)。走哪几步由 `Onboarding`(`app/ui/Onboarding.kt`)按「看过没有」判断:还没有主模型、也没在上手流程里配过模型时才走前两步(已配过模型的直接跳过);手机操作沿用原来的判断(系统够新、还没就绪、引导没看过)。
- **界面语言**(`app/i18n/`):界面文字都是安卓字符串资源,英文是默认(`res/values/`),中文在 `res/values-zh/`,两份条目一一对应(`StringResourcesTest` 核对条目、占位符,以及英文里没留下中文);繁体中文系统按文字体系匹配不到 `values-zh`,构建时把同一份中文复制成 `values-b+zh+Hant`(`app/build.gradle.kts` 的 `hantStrings`),所以中文系统都显示中文、其他语言的系统显示英文。设置 ›「语言」可选跟随系统、中文、English(`AppLanguage`):安卓 13 起交给系统的「应用语言」(`LocaleManager`,清单里 `android:localeConfig` 列出 en 与 zh-Hans,系统设置里能改的是同一个值,系统负责记住和重建界面);安卓 11、12 记在本地,经 `AppLanguage.wrap` 套到各 Activity 的 `attachBaseContext` 和悬浮胶囊的 Compose 宿主,换了之后重建当前界面。Compose 里直接用 `stringResource`;界面以外算文字的地方(状态文案、通知、任务服务、胶囊的数据、工具的说法)用 `tr` / `trPlural`(`app/i18n/UiText.kt`),取的是按 App 语言的资源,JVM 单测里换成直接读资源文件的 `ResFileTexts`(断言中文的测试装 `ChineseTexts`,真机测试用 `ChineseUi` 临时把应用语言换成中文)。数量用复数资源,日期、星期、月份按 `uiLocale()` 写。工具的说法以工具声明里的中文 label 为键查资源(`ToolLabels.TOOL_LABELS`)。只翻界面:内置的 App 知识、技能、提示词照旧是中文;模型写的回答、提问、标题跟着用户说话的语言。