feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图

## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
This commit is contained in:
2026-09-02 14:16:46 +08:00
parent 00ba69d899
commit 07e6b789b2
61 changed files with 3107 additions and 595 deletions

View File

@ -800,8 +800,40 @@ execute: {
- [x] `limit` 夹到 [1,200]非法值回落默认 40分页参数不该因笔误让整个请求失败
`limit=1` 时两方向各保底 1否则算出 `downLimit=0` 连锚点自己都不返回
剩余与树视图独立
- [ ] 工作列表卡片视图中间栏
**工作列表卡片视图(已完成)**
中间栏原先只有一种呈现 —— 紧凑列表行三行显示 `agent / path / .alias` N · 时间」。
它答得了跟谁在聊」,答不了在聊什么进展如何」:一条线索是一件正在进行的工作
而工作的状态在列表上完全看不见必须逐条点开
- [x] `WorkCard.tsx`主题平台模型生成的摘要+ 最新一封的发件人与摘要 + 往返预算徽标
- [x] **两种视图共用同一份数据与同一套动作**打开/写信/归档只有单项渲染不同
容器滚动/空态/归档确认留在 `ContactPanel`
- [x] **归档确认框抽成 `ArchiveConfirm` 两视图共用**归档是破坏性操作
换个视图就换套确认 UI 只会让人对自己点了什么更没底
- [x] 视图偏好存 `localStorage`纯展示偏好不值得建表加 API
而每次刷新退回默认视图会让人反复点同一个按钮读写都容错隐私模式会抛异常
- [x] 卡片视图把中间栏从 320px 放宽到 400px两行摘要 + 预算条挤不下
窄屏仍是 `w-full`
- [x] 预算徽标在不限」(max=0时**不显示**一个对每张卡片都成立的「0/0」是纯噪声。
1 个来回转橙用尽转红 —— 那是需要人介入的时刻
- [x] 数据一次取回`ListContactsFor` 增补 `subject`/`max_rounds`/`used_rounds`/
`last_from`/`last_preview`不让卡片为每条会话再打一次库
摘要** rune 截断**中文一字三字节裸切会留 U+FFFD
**顺带修掉的时间戳精度问题**卡片的最新进展要取会话里最后一封邮件
SQLite `CURRENT_TIMESTAMP` 只有****精度 —— 同一秒内插入的多封邮件
`created_at` 排序结果不确定实测同秒插 5 顺序是乱的由随机 UUID 决定)。
最早那封」(决定联系人身份同样会取错
- [x] `db.NOW()` 升到**微秒**毫秒不够一次插入只要几十到几百微秒
循环里连插几封会落在同一毫秒)。实测确认驱动能原样扫回 `time.Time`
- [x] `mails` 的三条 INSERT **显式传 `NOW()`** schema 默认值只对新库生效 ——
`CREATE TABLE IF NOT EXISTS` 不改已存在的表 SQLite 没有 `ALTER COLUMN`
- [x] 所有 `ORDER BY created_at` `mail_id` 兜底老数据仍是秒精度
没有第二排序键时同秒行的顺序由存储引擎决定翻页会重复或漏行
- [x] 测试用**显式发号的时钟**而非挂钟测试在循环里连插几封很可能落在同一微秒
而生产里两封邮件之间至少隔着一次模型推理
### 7.2 抄送(已完成)/ 转发
@ -817,18 +849,60 @@ execute: {
- `Fwd:` 前缀不叠加`parent_mail_id` 指向原邮件以便回溯
- 附件一同带过去内容寻址下只新增元数据不拷磁盘文件
### 7.3 配额机制(已完成;7.9 进一步下沉到会话
### 7.3 配额机制(已完成;语义经两轮修正
**只限制主动发信,不限制收信** —— 卡住收信只会让邮件凭空消失卡住发信才能阻止 Agent 无限自我循环
**最终形态:额度只有一层 —— 本任务(会话)的往返预算。**
- [x] `agents.max_rounds` / `used_rounds` 计数`max_rounds = 0` 表示不限
- [x] 配额用尽后 `send_mail` `forward` 均返回 403文案提示先发最终总结或联系管理员重置
- [x] **判断与自增在同一条 UPDATE 里**`WHERE used_rounds < max_rounds`
分成两步的话并发发信会双双通过检查再各自 +1把配额刷穿单测覆盖此场景
- [x] 剩余次数随发信响应`quota_remaining`与心跳`quota`回传
插件把它写进工具返回值与日志 Agent 在耗尽前主动发总结
- [x] 管理员 API`GET /admin/quotas``PUT /admin/quotas/{name}`设上限或归零
- [x] 前端管理员页新增发信配额tab`QuotaPanel.tsx`进度条 + 就地编辑 + 重置
第一版做的是 `agents.max_rounds` 终身额度用户两次纠正后才对齐到正确模型
1. 配额应当是在新建邮件以及邮件对话页面是可编辑的」→ 预算下沉到会话
2. 为什么会有全局配额不是每次单独配置配额然后有一个默认配额吗?」→
终身额度整个是错的工具
**为什么终身额度是错的**它跑满后要管理员手工重置才能再干活
Agent 是长期在线的 —— 那是把一次性资源的模型套在长期服务上
而且一个全局计数器让并行任务互相抢额度给紧急任务留的份被另一条线索吃掉
- [x] `sessions.max_rounds` / `used_rounds`真正的额度每条会话独立计数
- [x] `agents.default_rounds`默认 20派给该 Agent **新任务**默认几个来回
Agent 配而不是全站一个数 —— 跑测试的小工具与重构整个模块的 Agent
合理来回数差一个量级
- [x] 写信不给 `max_rounds` 时取收件 Agent 的默认值写信页把它显示为
输入框 placeholder人该看得到不填会是多少」,否则得先去管理员页查
- [x] `agents.used_rounds` **降级为纯统计**只累加不拦请求
保留是因为这个 Agent 一共发了多少信有观测价值
`BumpSentCount` error 都不返回 —— 统计写失败不该让邮件发不出去
- [x] 管理员页 tab 发信配额改名默认预算」,列出
默认来回数 + 进行中任务数 + 累计发信数不再有重置按钮
累计数是历史归零它只会销毁信息
- [x] `PUT /admin/quotas/{name}` 兼容旧字段名 `max_rounds`
已部署的前端与脚本不该因为改名就难以察觉地失效
- [x] 心跳不再回传额度额度不属于 Agent剩余往返随发信响应的
`budget_remaining` 回传在那里才有意义
**新建会话速率限制**替代终身额度的防滥用手段
预算按会话计Agent 就可以用 `.new` 开一串新会话每条都是全新预算
- [x] `repo/sessionrate.go`滑动窗口同一 Agent 1 小时最多新建 20 超出 429
- [x] **判断与记账在同一把锁里**分开的话并发请求会双双通过检查把上限刷穿 ——
与配额那条 UPDATE 同样的道理单测用 80 并发验证恰好放行 20
- [x] 建会话失败时 `ReleaseNewSession` 归还名额那次新建实际上没发生
- [x] 429 而不是 403前者表示稍后再来」,后者表示你没这个权限」,
客户端据此决定重试还是放弃
- [x] 被限速的 Agent **仍可在已有会话里回信** —— 不是全面封杀
也不禁止 Agent 主动开新会话那会堵死 Agent 之间的主动协作
- [x] 人类不受此限手工点新建邮件的频率天然受限
加限制只会在批量派活时误伤实测人类连开 25 条会话全通
- [x] 省略 session 位的默认会话不计入一个 `name@path` 只有一条
不构成暴开手段
- [x] 已知取舍进程内内存计数与登录限速同一取舍多实例部署时各自计数
等效上限变成 N
**实测 11 组**新注册默认 20 / Agent 分别设tiny=5/ 派活自动取默认值 /
显式值优先 / 22 封连发确认终身额度不再拦 21 封被会话预算拦下/
对话页调高后可继续 / 暴开 24 条会话恰好放行 20 / 人类连开 25 条全通 /
被限速仍能回信 / 默认会话不计入 / 老库补 default_rounds 且旧统计不丢
### 7.4 会话别名动态更新(已完成)
@ -1021,10 +1095,9 @@ execute: {
续谈也接受的话每封新信都会悄悄改掉对方正在遵守的预算
- [x] 对话页里改`GET/PUT /sessions/{id}/budget``max_rounds` `reset` 可同时给
(「加到 20 并从头算是一次很自然的操作拆两个请求只多一次往返
- [x] **两层都要过**会话预算 + Agent 全局配额少了后者Agent 自己 `.new`
开一串会话每条都是全新预算全局上限形同虚设
- [x] 会话预算先扣全局配额后扣全局拦下时 `RefundSessionBudget` 退回 ——
那次往返实际上没有发生不能白掉一格
- [x] 额度只有这一层后续修正原先叠了一层 Agent 终身额度
但那种额度跑满要人工重置才能再干活已降级为纯统计 7.3
- [x] 绕过手段Agent `.new` 开一串会话由新建会话速率限制堵住 7.3
- [x] 判断与自增在同一条 UPDATE`WHERE used_rounds < max_rounds`
40 并发 vs 上限 10 的单测覆盖`-race` 通过
- [x] 允许把上限调到低于已用次数那表示就到这里为止」,是人的合法意图
@ -1032,6 +1105,25 @@ execute: {
点徽标就地编辑可改上限/重置/取消预算变更广播 `session_update`
- [x] 老库补列默认 0不限引入预算不该把已在进行的会话卡死
**Agent 侧标记已读(这一轮顺带修的真实缺陷)**
原实现 Agent 只能读收件箱没有任何办法把邮件标掉 —— 生产库里 `opencode` 名下
积了 31 封未读每次 `read_inbox` 都把同一批旧邮件重新捞出来
处理过的信和新来的信混在一起模型分不清哪封该回心跳里的未读数也只增不减
- [x] `POST /mail/read`Agent 认证 `mail_ids` 标指定几封不给则全部标掉
- [x] **鉴权写进 `UPDATE` 的 `WHERE`**`to_name = $1 OR cc 含 $1`而不是先查后改
不是发给自己的邮件根本改不动既省一次查询也没有查完到改之间邮件被转走的窗口
- [x] 别人的 id 混在批次里不报错只是不被标掉 —— 报错会让整批失败
Agent 通常把上一轮列出的 id 原样传回其中可能混着已读的幂等
- [x] 全部标掉排除已归档会话那些邮件在收件箱里看不到
标了只会让标记了 N 与用户看到的对不上
- [x] 一批上限 200畸形 id 一律 400不静默跳过那会让调用方以为标成功了
- [x] 插件 `read_inbox` 读完自动标掉**本次列出的那些**不是全部未读 ——
limit 之外的还没看过一并标掉等于让它们凭空消失
标记失败不让 `read_inbox` 失败代价只是下次重复看到
- [x] `markread_test.go` 5 个用例 + 端到端 10 含抄送归档 Agent 越权
**验证**单测 `relay_test.go`10 个用例免配额类型恰好两种的防扩散断言+
`budget_test.go`6 40 并发不刷穿)。端到端 8
自主发信扣额 用尽后 relay 照样发出且不扣 key 幂等 key 可再转
@ -1106,14 +1198,59 @@ MVP 计划Phase 1-6已全部落地并在 systemd 部署态实测通过。
- [x] Agent 在邮件正文里主动提议改会话别名平台命名自动同步已完成
- [x] 插件自动转发平台原生权限询问与最终总结不消耗配额
- [x] 配额下沉到会话写信时给对话页里随时改
- [ ] 工作列表卡片视图中间栏
- [x] 工作列表卡片视图中间栏与列表视图切换
- [ ] DeepSeek Harness 插件`dsh-mail-bridge`
- [ ] 跨主机 Agent 发现Gateway + Registry 拆分
### 7.10 窄屏适配(已完成)
原实现只有三栏并排60导航+ 320列表+ 详情 375px 屏上详情栏被挤到
不足 0 —— 用户反馈窄屏基本不可用」。
第一版我做成了窄屏一次只显示一栏」(分栏切换用户纠正应当是
**新页面覆盖老页面并带动画**于是重做为覆盖式
- [x] `useIsNarrow()``matchMedia('(max-width: 767px)')`
matchMedia 而不是监听 resize —— 后者每变化一像素都触发还得自己节流
前者只在跨过阈值时回调一次
- [x] `NarrowStack`底层列表**始终挂载**覆盖层详情绝对定位盖在上面
两个实际好处列表滚动位置与选中态天然保留退出动画有东西可播 ——
直接卸载再渲染另一个组件的话没有任何一帧能让旧页面往右滑出去
- [x] 因此必须区分逻辑上是否打开是否还在 DOM 」:
关闭时先播 200ms 滑出动画结束才卸载
- [x] **入场用双层 requestAnimationFrame**必须让浏览器至少绘制一帧
在右侧之外的状态否则挂载与 `translate-x-0` 在同一帧内完成
transition 根本不触发单层 rAF Safari 上偶尔仍被合帧
- [x] `motion-reduce:transition-none` 尊重 `prefers-reduced-motion`
- [x] 打开覆盖层时底层 `aria-hidden`否则屏幕阅读器会读到两层内容
- [x] 导航竖条在窄屏退化为抽屉60px 在手机上白占一成宽度
日常切换交给底部 `NarrowNav`拇指够得到
抽屉带遮罩点空白处收起
- [x] `env(safe-area-inset-bottom)`iPhone 手势条会盖住最后一排
- [x] **窄屏专属控件用条件渲染而非 `md:hidden`**后者只是视觉隐藏
元素仍在 DOM tab 序列里宽屏用户按 Tab 会聚焦到看不见的返回按钮上
为此抽了 `NarrowOnly` / `BackButton` / `NavToggle` 三个组件
- [x] 列表栏 `w-full md:w-[320px]`各页横向内边距 `px-4 md:px-6`
px-6 375px 屏上白吃 48px
- [x] 管理页的 3/4 grid 改响应式列表行 `flex-wrap`
宁可占两行不要把每列挤成看不清的窄条
- [x] 详情页与写信页都有返回出口 —— 否则窄屏进去就出不来
写信页用 `cancelCompose` 而不是 `showList`写信态要一起结束
只滑走覆盖层的话下次进列表又会弹回来
- [x] `narrowPane` 在宽屏下**也维护**否则从窄屏拖宽再拖回来
用户会发现自己回到了列表刚打开的邮件不见了
- [x] `web/test/narrow-layout.test.mjs`16 条结构性断言
钉住覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden
无裸 px-6等不变量不做视觉快照 —— 那需要 headless 浏览器
且像素比对在字体差异下极脆
---
已知取舍尚未处理
- 前端只有 Markdown XSS 一个回归测试没有组件级测试
- 深色主题未做
- 窄屏已适配7.10但没有真机 / headless 浏览器的视觉回归只有结构性断言
- SQLite 抄送查询走 `json_each` 全表展开无索引单机量级下够用
百万级邮件时需要加物化列或换回 PostgreSQL
- 登录限速是进程内内存计数多实例部署时失效MVP 单实例暂不需要