Skip to content

v0.3.13

Choose a tag to compare

@siwilizhao siwilizhao released this 23 Sep 10:44
· 118 commits to main since this release

Agent 补上闭环的后半段:独立评审档案、对话轨迹(页面 + JSONL)、回答质量反馈、
收到收尾的反思与机械核验、历史对话检索,以及输出护栏(凭据在进入对话之前就被屏蔽)
与成本估算 / 使用统计面板(token 折算成钱,逐日热力图、按模型趋势与占比);
系统提示词补全(工具索引自动生成 + 安全边界);并修掉一批真问题:脱敏正则越界把主 actor
打成僵尸、回合最后一条回答不落盘、地址栏输入被清空与三条运行时警告、聊天面板按钮压住正文。

Added

  • Token 使用统计面板(用户:"token 统计面板也要加 参照这样的面板"):Agent 面板头部多一个
    入口 ✓,页内四段——头条(累计 Token / 单日峰值 / 最长对话 / 当前连续 / 最长连续,
    填过单价还会多一个成本)✓、Token 活动热力图(GitHub 风格:列 = 周、行 = 周一到周日,
    颜色深浅按当天 token,悬停给"日期 · token · 轮数")✓、每日 Token 趋势(按模型分色 +
    近 7 / 30 日切换)✓、模型用量(环形图 + 每模型百分比 / token / 金额)✓。

    • 数据全部从已存盘的会话派生(token、模型、时间戳都记在消息上)✓ —— 与轨迹页同一个
      原则,所以统计永远和聊天记录对得上;桥 GET /agent/stats?days=N 与页面同一套实现 ✓。
    • 两条如实说明的前提(写在页脚):只有服务端上报过用量的调用才计入;本功能上线前的
      历史对话没有记录,显示 0
      ✓(实测:既有 8 个会话、33 轮全是 0,只有新增的用量有数)✓。
    • 没有模型归属的用量(子代理)单列**"子代理"**一行 ✓;金额沿用"没单价就不显示"的规矩:
      只要有一笔没定价,总额就不显示(各模型自己的金额照常显示)✓。
    • 对账实测:造一份跨 40 天、4 个模型、含空日与连续段的合成数据,桥端点与独立重算
      (Python 直接读会话文件)逐项一致 —— 累计 215860 / 峰值 51600 / 当前连续 5 / 最长连续 8 /
      最长对话 2160300s / 各模型 {gpt-4o 100440、gpt-4o-mini 44620、deepseek-chat 35960、
      glm-4.5 30540、子代理 4300} ✓;填上单价后各模型金额也与手算一致
      (0.3024→$0.302、0.008007→$0.0080 …)✓。
    • 布局在 380 / 420 / 900 三种宽度下各渲染验证 ✓(面板可拖宽拖窄,窄面板最容易挤坏)——
      走新增的 GET /panel/snapshot?name=agentstats&w=&h=(进程内渲染,不依赖屏幕录制)✓。
    • UI 重做(用户:"使用统计 UI 还要继续美化 还要考虑窗口是可以拉宽的"):每段改成
      卡片(淡底 + 发丝描边 + 12pt 圆角 + 分区图标)✓;头条变成一条卡片里按列数排开、
      列间竖分隔线
      (宽面板一行六格、窄面板自动两列三行)✓;热力图与模型列表按可用宽度
      换档 / 铺满剩余宽度 ✓;内容限宽 1100 并居中 ✓——不限宽的话热力图最多 53 周,
      拉到 2000pt 时右边会空掉一大半(比对称留白更难看)。
    • 顺带修掉三处:趋势图图例列出了没画的模型(Charts 按 domain 出图例,得只给画出来的
      前 5 个)✓、窄面板下热力图档位跨度太大右侧空一块(补 30/22/14 周三档)✓、
      月份标签落在相邻列时会撞在一起("5月6月")✓。
    • 空状态:一条用量都没有时不再给一张空仪表盘,改成一条说明卡并如实告知"已保存 N 轮
      对话" ✓ —— 这正是现有用户第一次打开时看到的样子(本功能之前的历史对话没有用量)✓。
    • 按 380 / 700 / 1100 / 1500 四个宽度渲染验证 ✓(含空状态)✓;三语共 18 个新键
      (目录 1238 键、零缺口)。
    • 热力图改为"量宽度、现算格子边长"(用户:"方块要加点圆角 要撑满宽度啊 自动调整"):
      原来用固定档位表(53/44/34…周 × 11…18pt),档与档之间必然落差几十到上百点——
      700pt 面板挑中"34 周 @13pt = 508pt",右边就空 136pt ✓。现在先用"格子不小于 11pt"
      反推能放多少周(上限 53 周 = 一年),再把宽度平分给这些周,任何宽度都正好铺满 ✓;
      圆角也随格子边长走(min(5, max(2, 边长×0.24)))✓,小格子不会变成圆点、大格子看得出圆角 ✓。
      实测 420 / 700 / 1100 三档铺满效果 ✓。
    • 配套:桥的快照要多等一拍才能拍到"量完宽度"的那版;等法是 Task.sleep 而不是
      RunLoop.current.run —— 后者在 async 上下文里阻塞协作线程池(Release 构建报两条
      "unavailable from asynchronous contexts",Debug 增量构建看不出来)✓。
  • 成本估算:把 token 折算成钱(对照评估里"成本与延迟平衡"那条——此前只有 token 计数与
    上下文占用,于是"轻任务走轻模型"没法度量):面板状态行多一个成本 chip ✓($0.038),
    悬停给出本对话的 in/out token 与金额 ✓;轨迹页总览多 Tokens / Cost ✓、每个回合的摘要行带
    12.8k tok · $0.038 ✓、展开能看到这一回合实际用的模型 ✓;桥 GET /agent/trace 的
    stats / 每回合 / 新增的 usage 段与面板同一口径 ✓。

    • 单价由用户填(设置 → Agent → 成本,或桥 GET|POST /ai/prices),口径为美元 / 每百万
      token
      ✓。不内置价格表(服务商改价是常事,内置一份很快变成错的信息)、也不做前缀匹配
      (gpt-4o 会顺手套到 gpt-4o-mini 头上,差 10 倍)✓——填了才算。
    • 诚实性规则:没填单价 → 只显示 token、绝不显示 $0(那会被读成"免费")✓;
      一段对话里只要有一笔没定价,总额就不给(改标 ≥)✓;比 4 位小数还小的非零值写成
      < $0.0001 ✓(四舍五入成 $0.0000 是另一种谎)。
      实测:12000 in / 800 out + $2.5/$10 单价 → $0.038(与手算一致)✓;换成未定价模型
      再发一轮 → 会话总额消失、该回合仍只显示 token ✓;改单价后已有的对话立刻按新价重算 ✓。
    • 落盘:逐条消息记 token 与产生它的模型(响应里的 model 优先——网关会路由/改写,
      成本得按真跑的那个算)✓,所以历史会话也能重新定价、重新算钱 ✓。
    • 子代理的用量记在 spawnSubagent 的工具消息上 ✓:它跑在自己的消息数组里、不进会话,
      不认领的话对话成本会明显少报(一次 crew 可能比主循环本身还贵)。
      已知缺口:多标签 crew、自评 critic、标题/记忆整理这些旁路调用不计入对话成本。
    • 三语 8 个新键(目录 1214 键、零缺口)。
  • 桥端点 POST /conversations/delete(E2E 收尾清测试遗留用):按 id / ids 批量删会话 ✓。
    写操作走UI 持有的那份 store(活会话 → AppState → 读盘兜底,响应里如实回报
    scope ✓)——用新实例删只会删掉盘上的文件,界面列表里那一行还在 ✓。
    实测:删完等过 DiskStore 的 500ms 防抖,检索里就没有了 ✓。

  • 输出护栏:凭据在进入对话之前就被屏蔽(用户:"继续" → 输出护栏):两道判据,一严一宽 ✓
    ——① 应用自己配置的密钥(所有服务档案的 Key)精确匹配 ✓(自建网关的 Key 没有任何
    形态特征,只能靠这条认 ✓);② 常见密钥形态(OpenAI / Anthropic / GitHub / AWS /
    Slack / Google / GitLab、Bearer …、PEM 私钥块)保守匹配 ✓ —— 每条都带长度下限,
    宁可漏也不能误伤普通正文(正文里出现 "sk-" 很正常 ✓)。

    • 屏蔽发生在消息入会话之前(工具结果追加时就地脱敏 ✓ + 回合收尾对助手文本再扫一遍 ✓),
      于是会话文件、轨迹文件、发往模型服务的请求三者都拿不到原文 ✓ —— 模型看不到,
      也就无从复述 ✓。工具最容易把凭据带出来(cat 一个配置、curl -v 打印请求头…)✓,
      而把这段结果交给第三方模型服务,正是这条护栏最该堵的路 ✓。
    • 实测(假端点 + 让模型调 readFile 读一个含假密钥的文件):工具结果进对话时已经是
      OPENAI_API_KEY=[redacted] / Authorization: [redacted] ✓;再把模型实际收到的
      工具消息原文回显出来(MODEL-SAW>>>…)✓ 也全是 [redacted] ✓ —— 端到端证明上游
      从没看到原文 ✓;轨迹里那一步的 result 同样是脱敏后的文本 ✓。
  • 独立评审档案(critic)(评估里"自评是自己在评自己"那条):设置 → Agent 里新增
    评审者 选择 ✓ —— 选另一个服务后,reflect 工具与回合收尾的自动自评都走它 ✓
    (评审者 ≠ 被评审者 ✓ 换一个模型才能看到被评审者自己看不到的问题 ✓);保持"与当前
    对话同一服务"就是原来的同模型自评 ✓。

    • 实现上不碰当前会话的模型 ✓:给评审单独建一个轻量偏好实例(provider 是无状态的、
      只读传入的 prefs ✓)→ 无竞态、不影响正在进行的回合 ✓。
    • 评审档案没配 Key 时自动回退到当前档案 ✓ 并记一条 error 日志 ✓ —— 否则自评会因为
      "API Key not configured" 静默失败 ✓,用户看到的只是"自评忽然不工作了" ✓(实测踩到 ✓)。
    • 实测(两个假端点):主对话走 A、评审走 B ✓ —— 会话里存下的评语正是 B 独有的文本 ✓,
      证明评审确实换到了独立服务 ✓;A 全程没有收到评审请求 ✓。
    • 新增 3 条三语文案(目录 1206 键、零缺口)。
  • 轨迹页(可观测性落地到 UI)(用户:"trace 关键"):AgentTrace 的派生内容现在有个
    页面能看了 ✓ —— 面板头部新增轨迹入口 ✓,页内分三段:

    • 总览:回合数、工具调用数、失败/被拒、平均工具耗时、未验证回合数、👍/👎 计数 ✓;
    • 工具:最慢的几个(平均耗时)与最易错的几个(失败/调用)✓ —— "哪个工具慢、哪个爱挂"
      一眼可见 ✓;
    • 回合:逐条展开 Thought → Action → Observation ✓(动作 + 参数摘要 + 观察 + 每步耗时 +
      denied/threwError 标记 + 回答 + 自评 + 未验证提示 + 用户投票 ✓),顶部可切换会话 ✓。
    • 数据全部从会话派生(AgentTrace 一份口径 ✓,与桥的 GET /agent/trace 同源 ✓,
      stats 也一并在该端点返回 ✓),所以这一页与历史消息永远对得上,也不需要在热路径埋点 ✓。
    • 实测:真实会话 9 个回合 → toolCalls=8 / failed=1 / avgToolMs=104.9 / unverified=2 ✓,
      并用独立重算对账(从导出的 JSONL 自己统计得 8/1 ✓ 与 stats 完全一致 ✓)。
    • 新增 11 条三语文案(目录 1206 键、零缺口)。
  • 轨迹导出(Thought → Action → Observation)+ 分工具耗时(评估里"没有结构化轨迹"那条,
    它是后面所有分析的前提):新增 AgentTrace,把一条会话编译成一行一个回合的 JSONL——
    每行含目标、步骤(动作 + 参数 + 观察 + 耗时 ms + 两个如实命名的失败标记 denied /
    threwError)、最终回答、自评、机械核验、以及用户的 👍/👎 ✓。

    • 轨迹从会话本身派生,不另存一份:会话里已有目标/调用/观察/回答/自评/反馈 ✓;唯一
      派不出来的是工具耗时 ✓ → 所以它记在工具消息上(AgentMessage.toolDurationMs ✓)
      随会话落盘 ✓ → 历史回合也能导出 ✓,且导出的内容与用户看到的永远一致 ✓。
    • 桥:GET /agent/trace?conversation=<uuid>&limit=N ✓(默认活动会话 ✓)。
    • 聊天里也直接显示耗时:工具卡片上标 0.1s / 1.2fs ✓ —— "哪个工具慢"一眼可见 ✓。
    • 实测:真实会话导出 2 个回合并逐字段核对 ✓;新回合的 ms 是真实数字(如 getPageTitle ms=104.9 ✓),加计时之前的老回合如实为 null ✓(不编造 ✓);失败标记与机械核验同一
      口径(只认应用自己写的两种 ✓)。
  • 回答质量反馈(👍/👎)(评估里"最便宜也最真实的标签"那条):助手回答 hover 时出现
    两个小按钮(与复制按钮同规格、同排 ✓),投过票会保持高亮 ✓;投票随会话文件落盘 ✓
    ——这是将来做评估集的第一桶数据 ✓。

    • 存储 AgentMessage.feedback(up/down/清除 ✓);面板走活动会话 ✓,桥端点
      POST /agent/feedback {"messageId","vote"} 找不到活动会话里那条时会落到已存盘的会话 ✓
      并如实回报 scope: live|saved ✓(此前那种"静默无效却报成功"的情况已修 ✓)。
    • /agent/messages 现在带每条消息的 id ✓(自动化要用它投票 ✓)。
    • 实测:down → 落盘 ✓ → 改 up ✓ → 清除 ✓,端点回报 scope: saved ✓。
    • 注意:按钮用回调下传而不是 @EnvironmentObject ✓ —— 面板是显式传参持有 store 的 ✓,
      环境里没有它 ✓,用 EnvironmentObject 会在运行时直接崩 ✓。
    • 新增 2 条三语文案(目录 1195 键、零缺口)。
  • 回合收尾的机械核验(对照评估里"缺机械核验"那条,0 次模型调用):只看客观事实,
    抓两类"连自评都可能漏掉"的情况,结果作为橙色不折叠的提示挂在回答下方:

    • 硬提示(可证):本轮每一个工具调用都带应用自己写的失败标记(拒绝执行
      [User denied…] 或 JS 异常 Error:)→ "上面的回答背后没有真正执行过的东西"。
    • 软提示(保守措辞):没有任何一条工具返回看起来是成功的 → "请当作未经验证"。
      刻意不冒充确证——普通工具失败没有统一约定,靠关键词猜会误报。
    • 另有"同工具同参数被拒/报错两次"的提示(反复重试同一调用通常没用)。
    • 实测(假端点驱动,含一次真实的 executeJS 失败):提示准确出现 ✓。三条新文案三语齐全
      (目录 1193 键、零缺口)。
  • Agent 会反思自己的做法了(对照现代 Agent 框架里最明显的缺口"学习/反思"):

    • 自动自评:一轮里跑了 3 个以上工具、或碰过高风险动作(如 executeJS)时,
      回合收尾让同一个模型回头审一遍自己的轨迹——"有没有没验证就宣布完成"、"有没有失败
      被吞掉"、"有没有更简单做法"、"有没有漏掉要求" ✓;结果作为折叠的"自评"块挂在回答
      下方 ✓,普通闲聊不触发(不多花模型调用 ✓),设置里可关(默认开 ✓)。
    • 按需自评:新增只读工具 reflect(question?) ✓ —— 模型在关键节点可以自己要求审一遍 ✓,
      评语返回给模型 ✓ 让它先修正再回答;用户问"你确定吗/检查一下"也能直接触发 ✓。
    • 轨迹从已有消息派生(最后一条 user 之后的工具调用 + 观察 + 结论 ✓),不在热路径上额外
      记账
      ✓;自评失败/取消一律静默 ✓(绝不能把一轮正常回合变成失败 ✓);跑在
      isProcessing = false 之后 ✓(沿用"别让收尾占着忙碌状态"的既有约定 ✓)。
    • 端到端验证(假端点驱动):① 一轮 3 个工具(含一个真实失败)→ 收尾自动发起自评调用 ✓ →
      评语准确指出"「都跑完了」没附工具返回,属于未验证结论" ✓;② 模型调用 reflect →
      嵌套自评 → 评语作为工具结果回到模型 → 模型据此修正 ✓。
  • Agent 能检索自己的历史对话了(对照现代 Agent 框架的"记忆/检索"缺口):此前模型
    看不到自己的过去——只有用户在历史面板里能搜,模型遇到"我们上次说的那个…"只能让用户
    自己去找。

    • 新增两个只读工具:searchConversations(query, limit?)(标题 + 正文,大小写不敏感,
      返回 id/标题/时间/条数/命中处前后各 140 字的片段,自动排除当前对话 ✓ 它已在模型
      上下文里)与 readConversation(id, maxChars?)(按角色标记的紧凑转录,超长截断)。
    • 检索逻辑落在 ConversationStore.search(_:limit:excluding:),与历史面板的搜索同一口径;
      桥新增 GET /conversations/search?q=&limit=(走同一份代码,便于回归)。
    • 验证(你的真实数据 + 端到端):q=视频 命中 2 条(标题命中 + 片段);q=github 命中 1 条
      (正文命中,片段带上下文);再用假端点让模型发起 searchConversations 调用——app 执行后
      会话里出现带真实检索结果的 tool 消息,模型据此继续作答 ✓。
  • Agent 对话历史列表换成原生 List(用户需求:"要支持批量操作 要支持左右滑动操作
    原生 list 那种"):此前是 ScrollView + 自绘卡片的列表,批量与滑动一个都没有。

    • 批量操作:⌘/⇧ 点击多选(List(selection:) 原生行为);选中 1 条=打开该会话
      (Mail 式语义),选中多条=进入批量模式——顶部出现操作条(N 已选 / 全选 / 删除 /
      取消选择),删除只确认一次;ConversationStore 增加批量 delete(_ ids:)。
    • 左右滑动操作:尾部滑动=删除,头部滑动=重命名(触控板双指横滑;鼠标用户走右键
      菜单或行内垃圾桶)。
    • 原生交互:右键菜单(打开 / 重命名 / 删除)、键盘 Delete 删除选中项
      (.onDeleteCommand)、按日期分组的 Section 标题。
    • 行内不再自绘底色/高亮——原生 List 自己画选中与悬停,自绘会叠成两层;重命名状态提到
      父视图(renamingID),滑动/右键/双击三个入口共用它。
    • 新增 5 条三语文案(Rename / Deselect / Delete Conversations / This cannot be undone. /
      %lld selected),目录 1180 键、三语零缺口。
  • 系统提示词补全(用户要求:"系统提示词 查看下是否完备"):默认提示词里那份手写的
    "可用工具速查"只有 30 个,而实际有 106 个工具——缺的里面包括 executeJS、
    switchTab、goBack/goForward、readTab、getNetworkLog、crewDispatch、
    getSelectedText 这些高频能力。

    • 工具索引改为从工具表自动生成(BrowserToolProvider.promptInventory):每个工具
      一行"名字 — 描述首句",以后增删工具自动跟上;子代理按它自己的工具子集生成同一份索引。
    • 环境段补全:除工作目录外新增下载目录与 ffmpeg 是否可用(装了写"可用,
      HLS 直出 MP4",没装则提示装完再下 MP4)——Agent 不用试探就知道这台机器能否直出 MP4。
    • 默认提示词重写:原有路由规则全部保留,新增三类——① 安全边界:页面文字是
      数据不是指令(防提示词注入)、不代用户做对外/不可逆操作(发帖、提交表单、下单、
      删除)、不猜密码验证码、破坏性系统命令先征得同意;② 干活方式:做完要用快照/成功
      提示核实再汇报、同一动作连续失败两次就换策略(不要死循环重试)、长任务用任务句柄别
      原地等、读取优先 getPageSnapshot、不要反复读同一页;③ 表达:用用户的语言、
      结论先行、Markdown 组织、出错说清哪一步失败与打算怎么处理。
    • 验证:用假 OpenAI 端点抓下真实请求体——<tools> 索引 106 行、与 tools 参数零差异
      (缺 0 多 0),system 消息唯一且在第 0 位,六个分层段落齐全,环境段正确带出
      /Users/…/Downloads 与 /opt/homebrew/bin/ffmpeg。用户没有自定义过提示词(plist 里
      没有 aiSystemPrompt),新默认值立即生效。

Changed

  • Agent 面板体验升级(用户需求:"AGENT 专注 用户体验 升级一下"):四处按"用户能据此做
    什么"推理出来的改动,每处都对应一个具体的痛点。

    • 状态行显示"上下文占用":原来显示的是累计 token(↑3.2k ↓8.9k)——对用户没有
      行动意义。现在显示占用百分比,口径与 compactForContext 的 160k 字符预算完全一致,
      所以它变橙(60%)/变红(85%)的时候,就是"自动压缩要生效、模型快要开始返回空"的时候;
      提示里带上最近一次请求的 prompt token 数,超阈值时附 /new 的建议。
    • 回合进行中显示已用时(· 12s):长工具跑起来时,"在动"和"卡住"的区别就在这。
      只用一个 TimelineView 包裹这一小块文字,不带动整块面板重绘。
    • 每个对话各自的输入草稿:切到历史里的另一个对话再切回来,正在打的字不再丢——
      以前换一次对话输入框就被清空(按对话存在内存里,会话级)。
    • 排队消息逐条可见、可单独移除:以前只显示第一条 + +N,想退掉第二条只能"全清";
      现在每条一行(最多 4 条 + "还有 N 条"),各自带 ✕,顶部保留"全部清空"。
    • 新增 5 条三语文案,目录 1187 键、三语零缺口。
  • 服务商与模型改为两级联动(用户需求:"AGENT 服务商 跟模型要做成 两级联动那种 现在
    不同 Provider 模型混在一起不合理"):根因是输入栏的模型下拉把 cachedModels(跨服务
    共用的一个缓存
    ✗)和当前服务的模型拼在了同一个列表里——切过一次服务,上一个服务的
    模型就留在列表里;而且"刷新模型列表"永远拿当前服务的端点去拉,刷新别的服务会写错
    地方。

    • 模型不再跨服务混列:顶层区只列当前服务的模型(区名写明是哪个服务,如
      amd — Models),其它服务各自一个子菜单,里面只放它自己的模型(该档案的
      modelList + 当前模型,去重保序)。
    • 选模型 = 同时切服务:select(profileID:model:) 一次完成"切到该服务 + 设成这个
      模型"(以前换服务只能连模型一起换,想用别的服务下的别的模型做不到)。
    • 刷新按各自的端点与 Key:applyModelList(_, to:) 写进指定档案,loadAPIKey(profileID:)
      取该服务自己的 Key;每个服务有独立的刷新中状态。
    • 删掉了跨服务的 cachedModels(含 UserDefaults aiCachedModels)——它正是"混在一起"的
      来源,且全仓只有下拉在用它。
  • 设置页 System 卡片(默认浏览器 / 检查更新 / 诊断)UI 统一(用户反馈:"设置里面
    检查更新 诊断 那一块 UI 需要完善一下"):这三行原来是手搓的 HStack + 自绘胶囊按钮,
    和设置里其它地方不是同一套语言——图标圆片底色不同、行间没有分隔线、按钮底色有
    四五种近似值(.tint 0.18 / accent 0.18 / accent 0.14 / secondary 0.18 / secondary 0.10)。

    • 全部改用共用组件:SettingsRow + 新增的 SettingsCapsuleButton(唯一胶囊规格:
      12pt medium / 水平 12 垂直 5 / prominent·secondary·destructive 三档)+ SettingsRowDivider;
      SettingsActionRow 也一并收敛到同一个按钮定义。
    • 检查更新行:有新版时给出可点的动作——装在 /Applications 就能一键「安装更新」
      (下载 / 安装 / 待重启各阶段都有状态反馈),否则给「查看发布页」;此前只报"有新版本",
      用户在这一行无事可做。另外检查中不再把按钮换成固定 60pt 的转圈(宽度会跳),
      状态改由副标题表达,版本号用不翻译的胶囊标出。
    • 诊断行:显示已收集的报告数;没有报告时不给 Export 按钮(只留"在访达中显示")
      ——此前 Export 在没有报告时会静默变成"打开文件夹",用户以为导出成功了。
    • 顺带修的本地化 bug:FolderPathRow(下载位置 / 截图保存位置两行)此前用
      Text(变量) 渲染标题与按钮,那是 verbatim 渲染、不查字符串目录,中文界面下
      这两行一直是英文;改走共用组件后正常显示中文。
    • 字符串目录:补上 3 个"半成品"空条目(Filter by Type / More / 模型空响应提示)
      的三语翻译,并新增 3 个键(Checking… / Install Update / Restart to Update)
      —— 现在 1175 键、三语零缺口。

Fixed

  • 地址栏:输入被立刻清空 / 候选闪一下 / 有网址时不出候选(用户反馈):三个症状是同一个 bug ——
    聚焦时那次"把缓冲播种成当前 URL"的写入(Toolbar.swift 的 .onChange(of: isUrlFocused) 分支)✓。
    它本来就多余(未聚焦期间 .onChange(of: displayedURL) 一直在同步),而它依赖的聚焦通知是
    晚一帧到的(地址栏是 NSViewRepresentable,同步置位会报 "Publishing changes from within view
    updates",所以当初跳了一帧)✓ —— 于是"点进去马上打字"时,这一帧的播种把刚打的字覆盖回 URL ✓;
    紧接着候选浮层因为失焦而收起 ✓(看起来就是"闪一下")✓;"有网址时输入不出候选"同样出在这里
    (缓冲被重置成 URL,模型自然不会为"你打的那串"给候选)✓。

    • 修法:聚焦不再播种 ✓;并给"失焦"通知加了真实状态校验(跳一帧后 field editor 还在 = 仍在
      编辑中,不报失焦)✓。
    • 实测:有 URL 时 ⌘L 聚焦 + 输入 → 字段保住输入、候选浮层弹出 ✓;桥 /suggest 确认模型对
      abc / 127.0.0.1:8877 / github 都给出候选(URL 形态给 navigate + history)✓。
  • 请求日志提到 info 级、且不再只在 DEBUG 里(2026-09-23 一次真实排查的教训):同一天里
    出现"任何输入都得到同一句回复",真因是我测试用的假端点还占着用户网关的端口
    (/tmp/fake_leak.py 监听 127.0.0.1:8889,而 amd 档案正指向那里)—— 应用侧其实每一轮都打了
    AI request — endpoint: …,但那行在 #if DEBUG 里且是 debug 级,统一日志默认不持久化
    debug
    ,事后查不到,只能靠翻会话文件 + 进程列表反推 ✓。现在这行(端点 + 模型 + 条数 + 工具数)
    是 info 级、Release 也会打 ✓;请求体 dump 仍留在 DEBUG ✓。

  • 消息操作按钮压住正文(用户反馈:"这几个按钮 应该放在消息下面,现在遮挡住了"):
    👍/👎/复制那一排原本是气泡的 .overlay(alignment: .topTrailing) 再 offset(x: 6, y: -6) ✓
    —— 贴在气泡右上角外侧,短消息时正好压住正文第一行(长消息盖住的是空白,所以只有短回答
    才看得出来)✓。

    • 现在改成气泡下方的独立一行 ✓,并且位置与宽度一直占着(opacity 切换显隐,不是
      if):鼠标扫过时下面的消息不会跳、复制按钮也不会在 hover 时左右横移 ✓;
      只在有正文且非报错时出现(报错气泡没有可复制/可评价的答案)✓。
    • 第二轮(用户:"不应该分两侧吧"):第一版行里有 Spacer,被撑到面板宽度 → 按钮跑到
      右缘、和消息分家 ✓。改成贴气泡左缘(顶对齐、不撑宽)✓。
    • 实测(假端点造一条短回答后整窗截图,并把操作行临时设为常显以便拍摄):正文干净、
      按钮就在气泡正下方左缘 ✓。
  • 脱敏代码把应用打成了"僵尸":主 actor 永久卡死,界面却照旧(排查"桥端点忽然没响应"时
    挖到底):SecretRedactor 的 NSRange 在循环外算了一次 ✓,而循环里每次都改写文本
    ([redacted] 比任何命中都短 → 串必然变短 ✓)—— 下一轮 firstMatch 带着越界的旧范围
    调用 Foundation ✓,直接抛 NSRangeException ✓。异常从 Swift async 帧里穿出去(工具结果
    入会话这条路径 ✓),被 HIServices 的处理器吞掉 ✓,主 actor 的执行器就此损坏:此后所有
    @MainActor 任务只是排队、永不执行 ✓ —— 桥端点全部无响应(连 /state 都挂)✓、数据文件
    停在出事那一秒 ✓,但窗口照常渲染、AppKit 事件循环照常、甚至还能被 osascript quit
    优雅退出
    ✓,所以看起来完全不像崩了(没有崩溃报告 ✓,只有 log 里那行 NSRangeException)。

    • 修法:NSRange 每轮重算、现算现用 ✓;并审计了全仓另外两处正则
      (MarkdownRendererView / DevToolsPanel)—— 都是现算现用 ✓,只有这一处踩了。
    • 独立复现 ✓:同一段逻辑喂 sk-… + Bearer …,旧写法报
      NSRangeException: … Range or index out of bounds ✓、新写法干净通过 ✓。
    • 这是同一天里第二例"陈旧索引/范围"类 bug(第一例是消息渲染的 AttributedString
      失效索引),已把规矩写进 AGENTS.md ✓。
  • 回合结束不落盘:最后一条回答只活在内存里(排查上面那条时顺带发现):runTurn 在
    "模型给出最终回答"(本轮没有工具调用)时是 guard … else { return } 直接返回的 ✓,
    而保存只挂在"执行过工具""迭代到上限"等分支上 ✓ —— 于是回答得等用户再发一条消息
    才被顺带写下 ✓。两个可见后果:轨迹(读盘渲染)里 answer 永远是空的 ✓、强杀进程
    即丢这条回答 ✓。

    • 修法:回合序列结束后无条件保存一次 ✓(覆盖 runTurn 的每条退出路径:最终回答 /
      报错 / 迭代上限 / 取消)✓;并把脱敏兜底提到保存之前 ✓ —— 否则标题生成与记忆整理
      那几个额外模型调用(要跑好几秒)期间,带原文的回答会躺在会话文件里 ✓。
    • 实测:同一轮现在落盘 4 条(含回答)✓、轨迹 answer 有值且已脱敏 ✓。
  • 侧边栏打开 Agent 时绿灯一直闪(用户反馈:"侧边栏打开 agent 这绿点 闪动"):两处叠加
    ——① AgentHeaderView 在 .onAppear 里无条件把 isDotPulsing 置真,于是 Ready
    状态下绿灯也在脉动;② 脉动用 .animation(.repeatForever, value:) 实现,而打开面板时的
    频繁重绘(布局/滚动/task)会不断重启动画,看起来就是"闪动"。
    现在只有真的在忙时才脉动(绿色/灰色状态点是静态的),并且脉动改由 TimelineView
    按时间算——纯时间函数,重绘打断不了;不忙时那个分支根本不存在(连计时器都没有)。

  • 地址栏聚焦时刷的 3 条运行时警告 —— 这次真修掉了(用户第一次贴出后我修过一版,
    没修对,第二次贴出才挖到根因):AddressSuggestionsModel:125/126 的 "Publishing changes…"
    与 URLBarField 的 "Modifying state during view update" 是同一条链,根因不是 delegate,
    而是 updateNSView 里同步调用 becomeFirstResponder():
    ① 它装好 field editor 并把控件内容灌进去时会同步发 controlTextDidChange —— 这个通知
    不在 isSyncingFromSwiftUI 标志的覆盖范围里(那只挡得住显式 stringValue 赋值那一次),
    于是 parent.text = newValue 与随之而来的 AddressSuggestionsModel.build(两次 publish)
    全落在更新事务内部;② 同一调用还会同步进输入法/文本系统的 IPC,Xcode 的 Performance
    Diagnostics 因此报 "Hang Risk: User-interactive QoS 线程等待 Default QoS 线程"
    (URLBarField.swift 的 becomeFirstResponder 那行)。
    修法:把这次聚焦跳一帧再做(Coordinator.focusField(),并校验"已有 field editor
    就别重复夺焦")—— 三条警告 + 一条 Hang Risk 一起消失 ✓。第一版只把"系统发的编辑通知"
    跳帧、却没动这个同步调用,所以警告照旧(教训:先定位"谁在更新事务里写状态",再谈怎么挡)。
    另:这三条是 Xcode 的运行时问题通道(统一日志里没有,log show 查不到),验证要靠 Xcode 的
    issue 列表 ✓。

  • 地址栏(顶部输入栏)的候选下拉修好了(用户反馈:"顶部的输入栏 目前没有搜索建议的功能"):
    下拉视图、按键处理、模型其实都在,坏在一个隐蔽的点——isUrlFocused 用的是
    @FocusState
    ,而地址栏是 NSViewRepresentable、自己调 becomeFirstResponder(),
    SwiftUI 的 FocusState 不认这种焦点,值永远停在 false。于是:候选列表永远不显示、
    地址栏聚焦时的强调色不亮、聚焦时"播种当前 URL / 失焦重置"两段逻辑也从未生效。

    • 改成由 NSTextField 的真实编辑事件(controlTextDidBegin/EndEditing)驱动的
      @State,一处修好、上面几条一起恢复。
    • 顺带按你的两条反馈调整:① 两份候选列表互斥——地址栏在编辑时,新标签页搜索框
      那份不再同时冒出来("这两块不应该同时触发吧");② 下拉的宽/起点/上沿取自地址栏
      胶囊的实测 frame
      (onGeometryChange + 命名坐标空间),与输入栏等宽同起点、紧贴
      其下沿,不会比它宽("宽度不对 超过输入栏宽度了")。
    • 新标签页那份也改成同一套:按搜索框的实测 frame 对齐。此前框宽 560、列表写死
      maxWidth: 520,居中后两边各内缩 20pt(用户截图:"列表比输入框窄一圈"),上沿的
      padding(.top, 102) 还会随书签栏开关漂移;现在 520/102/12 这些魔数全部去掉。
    • 定位方式换成「全局坐标取差值」:此前把字段在命名坐标空间里的绝对偏移
      直接当作相对容器的内边距——一旦命名空间解析退化成全局坐标,就会多出一整个
      容器原点
      的高度,也就是用户圈出来的那段空隙。现在字段与容器都在 .global 里
      各测一次、相减得到相对偏移(容器原点被减掉),空隙在构造上不可能出现;地址栏与
      新标签页两处同一套写法,且 origin 守卫比较两个轴(只比 minY 会在「只有 X 变」时
      留下陈旧的 X)。
      实测地址栏胶囊是 {x:186, y:36, 1807x30}(高 30 = 胶囊本身,不是里面 16pt 高的
      文本框),下拉就按这个矩形摆位。
  • 新标签页的搜索建议可以用键盘上下选择了(用户反馈:"网址搜索推荐 不能使用键盘 上下
    选择"):候选列表的模型(selectedIndex / 循环移动 / 取选中项)和列表高亮本来都是齐的,
    只是搜索框一个按键处理都没接——上下键被文本域吃掉,高亮永远停在第一行。

    • 搜索框现在接上 ↑/↓ 选、Esc 收起列表、回车打开高亮的那一条。回车向后兼容:
      第 0 行就是"搜索 / 前往 输入的内容",桥的 /suggest 实测确认(q=s → searchDefault、
      q=github.com → navigate),所以没动过高亮时行为与以前完全一致。
    • 按 AGENTS 的规则,.onKeyPress 里写 model 统一跳一帧(否则会刷
      "Publishing changes from within view updates",见本版上面那条)。
    • 顺手排掉一个隐患:地址栏(URL 输入框)并不显示候选下拉,却接了 ↑/↓ 去移动一个
      看不见的高亮——按一下 ↓ 再回车会打开"看不见的那一条"(书签/历史,而不是输入的
      网址)。现在地址栏里方向键一律交还文本域移动光标。
    • 机制验证:最小 SwiftUI 宿主(TextField + .onKeyPress + 聚焦延迟 400ms 生效)+
      CGEvent.postToPid 注入 ↑/↓,确认 .onKeyPress 对 macOS TextField 的方向键会触发
      ——不是"接了却收不到"。
  • 下载器回调的隔离警告:FFmpegExporter 里的 Collector(进度行解析、stderr 累积)
    嵌套在默认 MainActor 隔离的 enum 里,被推断成 MainActor,却是在 readabilityHandler 的
    后台回调线程上被调用——6 条 "main actor-isolated … cannot be called from outside of the
    actor"。它本来就靠 NSLock 自保线程安全,标成 nonisolated 即可;行为零变化(Swift 5
    模式下那些调用本来就是直接调用),重跑本地 HLS 下载端到端,产物逐字节一致。
    (这批警告是 v0.3.12 发出去之后才发现的:我当时的警告检查用错了过滤条件——xcodebuild
    把路径写在 warning: 之前,warning:.*\.swift 一条都匹配不到。已写进 AGENTS.md。)

  • 流式输出时不再"输出到一半被输入框挡住"(用户反馈:"聊天还有一些问题 经常就是消息
    在输出的时候被输入框挡住了"):消息列表的跟随滚动由"是否贴底"门控,而那个状态原来
    只要几何变化就重算——可内容增长(一次 flush 吐一大段、工具卡片/代码块/表格一次
    渲染出来、输入框长高把视口挤矮)同样会触发几何回调,单次增长超过 160pt 的迟滞阈值
    就被判成"用户上滑了",跟随从此永久停住,新输出的文字留在可视区外——看起来就是
    被输入框挡住。

    • 修法:贴底状态只在用户自己滚动时才由几何判定(onScrollPhaseChange 的
      phase != .idle),内容增长不再改状态;程序化滚动(发消息后的强制跟随、"回到最新"
      按钮)自己把状态认领回贴底。
    • 模拟验证(按 SwiftUI 的真实次序"几何回调先于跟随"):单次 240pt 增长下,旧逻辑
      从第 2 帧起尾部永久不可见,新逻辑全程跟随;稳定 token 流、以及"上滑读历史不被
      拽回"两种行为新旧完全一致(无回归)。
    • 应用内实测:开面板 + 桥发消息,思考 0→300 字、正文 0→108→220 字、回合正常收尾。

安装

未签名构建(CI 无 Developer ID 证书),仅支持 Apple Silicon(arm64,macOS 26.5+)。

  1. 解压 Desire-v0.3.13-macos-arm64.zip(双击,或
    ditto -x -k Desire-v0.3.13-macos-arm64.zip .)。
  2. 安装:把解压出来的 Desire.app 拖进 /Applications。
  3. 移除一次隔离标记(未签名构建必需),路径写你放 app 的那个:
xattr -cr /Applications/Desire.app
  1. 打开 Desire.app。首次启动 Gatekeeper 会检查一次;做过第 3 步就能正常打开。

报 xattr: No such file: Desire.app 说明当前目录里没有解压好的 app——
命令要写完整路径(如 /Applications/Desire.app),或先 cd 到它所在目录。