v0.3.13
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 折算成钱(对照评估里"成本与延迟平衡"那条——此前只有 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 键、零缺口)。
- 单价由用户填(设置 → Agent → 成本,或桥
-
桥端点
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 键、零缺口)。
- 实现上不碰当前会话的模型 ✓:给评审单独建一个轻量偏好实例(provider 是无状态的、
-
轨迹页(可观测性落地到 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→
嵌套自评 → 评语作为工具结果回到模型 → 模型据此修正 ✓。
- 自动自评:一轮里跑了 3 个以上工具、或碰过高风险动作(如
-
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 键、三语零缺口。
- 状态行显示"上下文占用":原来显示的是累计 token(
-
服务商与模型改为两级联动(用户需求:"AGENT 服务商 跟模型要做成 两级联动那种 现在
不同 Provider 模型混在一起不合理"):根因是输入栏的模型下拉把cachedModels(跨服务
共用的一个缓存 ✗)和当前服务的模型拼在了同一个列表里——切过一次服务,上一个服务的
模型就留在列表里;而且"刷新模型列表"永远拿当前服务的端点去拉,刷新别的服务会写错
地方。- 模型不再跨服务混列:顶层区只列当前服务的模型(区名写明是哪个服务,如
amd — Models),其它服务各自一个子菜单,里面只放它自己的模型(该档案的
modelList+ 当前模型,去重保序)。 - 选模型 = 同时切服务:
select(profileID:model:)一次完成"切到该服务 + 设成这个
模型"(以前换服务只能连模型一起换,想用别的服务下的别的模型做不到)。 - 刷新按各自的端点与 Key:
applyModelList(_, to:)写进指定档案,loadAPIKey(profileID:)
取该服务自己的 Key;每个服务有独立的刷新中状态。 - 删掉了跨服务的
cachedModels(含 UserDefaultsaiCachedModels)——它正是"混在一起"的
来源,且全仓只有下拉在用它。
- 模型不再跨服务混列:顶层区只列当前服务的模型(区名写明是哪个服务,如
-
设置页 System 卡片(默认浏览器 / 检查更新 / 诊断)UI 统一(用户反馈:"设置里面
检查更新 诊断 那一块 UI 需要完善一下"):这三行原来是手搓的 HStack + 自绘胶囊按钮,
和设置里其它地方不是同一套语言——图标圆片底色不同、行间没有分隔线、按钮底色有
四五种近似值(.tint0.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)✓。
- 修法:聚焦不再播种 ✓;并给"失焦"通知加了真实状态校验(跳一帧后 field editor 还在 = 仍在
-
请求日志提到 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 高的
文本框),下拉就按这个矩形摆位。
- 改成由 NSTextField 的真实编辑事件(
-
新标签页的搜索建议可以用键盘上下选择了(用户反馈:"网址搜索推荐 不能使用键盘 上下
选择"):候选列表的模型(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对 macOSTextField的方向键会触发
——不是"接了却收不到"。
- 搜索框现在接上 ↑/↓ 选、Esc 收起列表、回车打开高亮的那一条。回车向后兼容:
-
下载器回调的隔离警告:
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+)。
- 解压
Desire-v0.3.13-macos-arm64.zip(双击,或
ditto -x -k Desire-v0.3.13-macos-arm64.zip .)。 - 安装:把解压出来的
Desire.app拖进/Applications。 - 移除一次隔离标记(未签名构建必需),路径写你放 app 的那个:
xattr -cr /Applications/Desire.app
- 打开
Desire.app。首次启动 Gatekeeper 会检查一次;做过第 3 步就能正常打开。
报
xattr: No such file: Desire.app说明当前目录里没有解压好的 app——
命令要写完整路径(如/Applications/Desire.app),或先cd到它所在目录。