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:
71
docs/API.md
71
docs/API.md
@ -142,8 +142,8 @@ GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
|
||||
### 会话
|
||||
|
||||
```
|
||||
GET /me/sessions 我参与的会话
|
||||
GET /sessions/{id} 会话详情
|
||||
GET /me/sessions 我参与的会话(含 max_rounds/used_rounds)
|
||||
GET /sessions/{id} 会话详情 + 会话内邮件(含附件)
|
||||
GET /sessions/{id}/mails 会话内邮件(含附件)
|
||||
PUT /sessions/{id}/alias 改会话别名(冲突 409)
|
||||
|
||||
@ -174,15 +174,20 @@ curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN"
|
||||
-d '{"max_rounds":20,"reset":true}'
|
||||
```
|
||||
|
||||
- `max_rounds = 0`(或省略)= 本会话不限,仅受 Agent 全局配额约束
|
||||
- **两层都要过**:会话预算 + `agents.max_rounds` 全局配额。少了后者,
|
||||
Agent 自己用 `.new` 开一串会话每条都是全新预算,全局上限形同虚设
|
||||
- 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 ——
|
||||
那次往返实际上没有发生
|
||||
- `max_rounds = 0` = 本任务不限来回
|
||||
- **省略 `max_rounds` 时用收件 Agent 的 `default_rounds`**(管理员页可按 Agent 配,默认 20)
|
||||
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
|
||||
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
|
||||
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
|
||||
|
||||
**Agent 用 `.new` 开一串新会话绕过预算**,靠新建会话速率限制堵:
|
||||
同一 Agent 1 小时内最多新建 20 条会话,超出返回 `429`。
|
||||
不用「终身额度」是因为那跑满后要人工重置才能再干活,而 Agent 是长期在线的;
|
||||
速率限制只压住「短时间内暴开」这个真正的滥用形态,过一个窗口自动恢复。
|
||||
人类不受此限(手工点「新建邮件」的频率天然受限),
|
||||
被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀。
|
||||
省略 session 位的「默认会话」也不计入:一个 `name@path` 只有一条,不构成暴开手段。
|
||||
|
||||
### Agent 提议改会话别名
|
||||
|
||||
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
|
||||
@ -218,6 +223,16 @@ POST /contacts/archive 归档
|
||||
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
|
||||
给 `name`+`path` 返回已有会话别名与 `new`。
|
||||
|
||||
`GET /contacts` 的每条记录除了地址与计数,还带着卡片视图所需的一整套状态:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `subject` | 会话主题(多由 Agent 平台的模型生成的摘要) |
|
||||
| `max_rounds` / `used_rounds` | 本任务的往返预算(0 = 不限) |
|
||||
| `last_from` / `last_preview` | 最后一封邮件的发件人与正文摘要(服务端已按字符截断到 90) |
|
||||
|
||||
这些字段与列表一次取回,不需要逐条会话再请求一次。
|
||||
|
||||
### 权限决策
|
||||
|
||||
```
|
||||
@ -264,10 +279,21 @@ POST|GET /admin/agent-keys 签发/登记 Agent 密钥
|
||||
DELETE /admin/agent-keys/{id}
|
||||
POST /admin/agent-keys/{id}/bind
|
||||
|
||||
GET /admin/quotas Agent 发信配额
|
||||
PUT /admin/quotas/{name} 设上限 {max_rounds} 或归零 {reset:true}
|
||||
GET /admin/quotas Agent 新任务默认预算 + 累计统计
|
||||
PUT /admin/quotas/{name} 设默认预算 {default_rounds}
|
||||
```
|
||||
|
||||
`/admin/quotas` 配的是**默认值,不是额度**。额度属于具体任务(会话),见「往返预算」。
|
||||
这里只决定「派给某个 Agent 的新任务,没人显式指定时默认几个来回」——
|
||||
跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||||
|
||||
```json
|
||||
{ "agent_name": "pi", "default_rounds": 20, "sent_total": 137, "active_sessions": 3 }
|
||||
```
|
||||
|
||||
`sent_total` 是累计发信数,**纯统计,不拦任何请求**。它原本是「终身额度」,
|
||||
但那种额度跑满要管理员手工重置才能再干活,而 Agent 是长期在线的 —— 已降级为观测数据。
|
||||
|
||||
## 四、Agent 接口
|
||||
|
||||
```
|
||||
@ -275,6 +301,7 @@ POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
|
||||
POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota
|
||||
POST /mail/send 发信(扣配额)
|
||||
GET /mail/inbox 收件箱(含附件清单)
|
||||
POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉)
|
||||
POST /mail/{id}/forward 转发(扣配额)
|
||||
POST /permission/request 请求人类决策
|
||||
POST /attachments 上传附件
|
||||
@ -282,9 +309,29 @@ GET /attachments/{id} 下载附件
|
||||
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
|
||||
```
|
||||
|
||||
发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。
|
||||
发信与转发扣**本任务(会话)的往返预算**。
|
||||
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
|
||||
|
||||
### 标记已读
|
||||
|
||||
```bash
|
||||
# 标记指定几封(一次最多 200 封)
|
||||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" \
|
||||
-d '{"mail_ids":["<id1>","<id2>"]}'
|
||||
|
||||
# 不给 mail_ids(或空 body)= 把收件箱里全部未读标掉
|
||||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
|
||||
```
|
||||
|
||||
没有它 Agent 每次拉收件箱都会重复捞同一批旧邮件,处理过的和新来的混在一起。
|
||||
插件的 `read_inbox` 会自动标掉本次列出的那些(只标列出的 —— limit 之外的还没看过)。
|
||||
|
||||
- 鉴权写在 `UPDATE` 的 `WHERE` 里:不是发给自己(也没被抄送)的邮件根本改不动
|
||||
- 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
|
||||
- 重复标记已读的邮件返回 `marked: 0`,不是错误(Agent 常把上一轮的 id 原样传回)
|
||||
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到,
|
||||
标了只会让计数与用户看到的对不上
|
||||
|
||||
### 免配额通道:harness 代劳的转发
|
||||
|
||||
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
|
||||
@ -312,8 +359,8 @@ curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
|
||||
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
|
||||
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
|
||||
|
||||
`max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。
|
||||
注意 Agent 全局配额与会话往返预算是两层,都要过。
|
||||
额度只有一层 —— **本任务(会话)的往返预算**。剩余次数随发信响应的
|
||||
`budget_remaining` 回传;心跳不再回传额度(额度不属于 Agent,属于任务)。
|
||||
|
||||
## 五、实时推送(SSE)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user