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

5
.gitignore vendored
View File

@ -12,9 +12,10 @@ plugins/*/node_modules/
#
# 但目录本身要留下go:embed 要求它存在才能编译,否则新克隆连
# `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。
# 因此忽略里面的产物,只保留一个占位 index.html见该文件内注释)。
# 因此忽略构建产物,只保留 placeholder.html见该文件内注释
# 之所以不叫 index.html是因为那正是 Vite 产物的名字,会被反复覆盖)。
gateway/internal/static/static/*
!gateway/internal/static/static/index.html
!gateway/internal/static/static/placeholder.html
# Go 构建产物
gateway/agentmail-gateway

View File

@ -105,6 +105,8 @@ DATABASE_URL= # 留空 = 内置 SQLit
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
`read_inbox` 读完会自动把本次列出的邮件标为已读,所以下次拉收件箱只会看到新来的。
**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志,
管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往
服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`
@ -178,17 +180,45 @@ curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
完整接口见 [WebAPI 文档](docs/API.md)。
## 工作列表卡片视图
中间栏可在**列表**与**卡片**之间切换(右上角图标,偏好存 localStorage
分工:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」——
卡片显示会话主题、最新一封的发件人与摘要、往返预算徽标。
预算徽标在「不限」时不显示一个对每张卡片都成立的「0/0」是纯噪声
剩 1 个来回转橙、用尽转红 —— 那是需要人介入的时刻。
## 窄屏适配
小于 768px 时布局从三栏切成**页面覆盖**:列表铺满整屏,点开邮件后详情页从右侧滑入盖在它上面,
返回时滑出。底层列表始终挂载 —— 滚动位置与选中态因此天然保留,退出动画也才有东西可播
(直接卸载再渲染另一个组件的话,没有任何一帧能让旧页面往右滑出去)。
左侧导航竖条在窄屏退化为抽屉,日常切换交给底部导航(拇指够得到),
并留了 `env(safe-area-inset-bottom)` 避开 iPhone 手势条。
窄屏专属控件(返回按钮、抽屉入口)用 `useIsNarrow()` 条件渲染而不是 `md:hidden` ——
后者只是视觉隐藏,宽屏用户按 Tab 会聚焦到一个看不见的按钮上。
动画尊重 `prefers-reduced-motion`
## 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
所以预算落在会话上,在写信时给、在对话页头部随时调
所以预算落在会话上:
- 新建邮件时填「往返预算」留空 = 不限)
- 对话页头部点预算徽标即可改上限,或把已用次数归零
- Agent 全局配额(管理员页)仍然生效,两层都要过 ——
否则 Agent 自己用 `.new` 开一串会话,每条都是全新预算,全局上限就形同虚设
- 新建邮件时填「往返预算」留空则用**收件 Agent 的默认值**
- 对话页头部点预算徽标即可改上限,或把已用次数归零 ——
人看着往来内容才知道这件事还值不值得再来几个回合
- 管理员页按 Agent 配默认值(默认 20跑测试的小工具与重构整个模块的 Agent
合理来回数差一个量级
插件自动转发的权限询问与最终总结**不占用**任何一层:配额约束的是模型的自主发信
没有「Agent 终身额度」这一层。那种额度跑满后要管理员手工重置才能再干活
而 Agent 是长期在线的 —— 它是把一次性资源的模型套在长期服务上。
Agent 用 `.new` 开一串新会话绕过预算,靠**新建会话速率限制**堵
1 小时 20 条,超出 429过一个窗口自动恢复人类不受此限
插件自动转发的权限询问与最终总结**不占预算**:配额约束的是模型的自主发信,
不是 harness 的搬运。
## 会话别名

View File

@ -18,9 +18,22 @@ echo "==> 构建前端"
( cd "$REPO/web" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund )
( cd "$REPO/web" && npm run typecheck && npm test && npm run build )
# 插件的纯函数测试(自动转发去重等)。
# 插件不参与构建产物,但它的逆行为会直接变成用户收件箱里的重复邮件,
# 因此也纳入部署前的门禁。zod 已在 node_modules 里就不重装。
if [[ -d "$REPO/plugins/opencode-mail-bridge/node_modules/zod" ]]; then
( cd "$REPO/plugins/opencode-mail-bridge" && npm test )
else
( cd "$REPO/plugins/opencode-mail-bridge" && npm install --no-audit --no-fund && npm test )
fi
echo "==> 前端产物嵌入 Gateway"
rm -rf "$REPO/gateway/internal/static/static"
cp -r "$REPO/web/dist" "$REPO/gateway/internal/static/static"
# 只清构建产物,不能 rm -rf 整个目录:
# placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库里带走了。
rm -rf "$REPO/gateway/internal/static/static/assets"
rm -f "$REPO/gateway/internal/static/static/index.html"
cp -r "$REPO/web/dist/." "$REPO/gateway/internal/static/static/"
echo "==> 构建 Gateway单二进制内含前端 + SQLite"
# 先删再建go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)

View File

@ -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

View File

@ -32,7 +32,9 @@
| WebAPI 等价接入 | ✅ | WebUI 与第三方客户端同一套 API见 docs/API.md |
| 对话树 | ✅ | 沿 parent_mail_id 递归展开,跨会话,按方向分块加载,不建 tree_nodes 表 |
| Agent 提议改会话名 | ✅ | 正文里的 HTML 注释标记,入库时剥除;改名需用户确认 |
| 会话往返预算 | ✅ | 写信时给 / 对话页随时改; Agent 全局配额两层都要过 |
| 会话往返预算 | ✅ | 写信时给 / 对话页随时改;省略则取收件 Agent 的默认值 |
| 新建会话速率限制 | ✅ | Agent 1 小时 20 条;堵住用 .new 绕过预算,人类不受限 |
| 窄屏适配 | ✅ | <768px 改为页面覆盖 + 滑入动画底部导航 + 抽屉侧栅 |
| 插件自动转发 | | 平台原生权限询问 + 本轮最终总结**不消耗配额** |
| 会话管理创建/列表/状态 | | 会话是协作的边界 |
| 权限请求与决策 | | Agent 需要人批准才能继续 |
@ -47,7 +49,7 @@
| 配额机制 | | 后续迭代 |
| 会话别名命名/改名 | | 发信时命名事后改名平台命名自动同步 |
| Agent 正文里主动提议改名 | | 后续迭代 |
| 工作列表/卡片视图 | ❌ | 后续迭代 |
| 工作列表/卡片视图 | | 中间栏可切列表/卡片卡片显示主题最新进展预算徽标 |
| DeepSeek Harness 插件 | | 后续迭代 |
| 跨主机 Agent 发现 | | 后续迭代 |
@ -773,7 +775,6 @@ src/
### 下一批MVP 已完成的部分不再列出)
- 对话树数据模型 + 前端渲染
- 转发功能抄送已完成
- 工作列表卡片视图(中间栏)
- Agent 在邮件正文里主动提议改会话别名平台命名自动同步已完成
- DeepSeek Harness 插件

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 单实例暂不需要

View File

@ -100,6 +100,9 @@ func main() {
r.Post("/agent/heartbeat", handler.HeartbeatAgent)
r.Post("/mail/send", handler.SendMail)
r.Get("/mail/inbox", handler.GetInbox)
// 批量标记已读:不给 mail_ids 就把收件箱全部未读标掉。
// 没有它的话 Agent 每次拉收件箱都会重复捞同一批旧邮件。
r.Post("/mail/read", handler.MarkInboxRead)
r.Post("/mail/{id}/forward", handler.ForwardMail)
r.Post("/permission/request", handler.RequestPermission)
// 附件:先上传拿 id再在发信时放进 attachment_ids

View File

@ -48,11 +48,23 @@ func init() {
return uuid.NewString(), nil
})
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻
// 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻
//
// 精度到微秒而不是秒SQLite 的 CURRENT_TIMESTAMP 只有秒,
// 同一秒内插入的多封邮件排序就不确定 —— 「会话里最早那封」(决定联系人身份)
// 与「最后那封」(决定最新进展)都会取错行。实测同秒插 5 封,
// 按 created_at 排出来的顺序是乱的(由随机 UUID 决定)。
//
// 毫秒还不够:一次插入只要几十到几百微秒,循环里连插几封会落在同一毫秒。
// 微秒是实测确认驱动能原样扫回 time.Time 的精度(纳秒也行,但没必要)。
//
// 格式仍是 SQLite 认得的 "YYYY-MM-DD HH:MM:SS.ffffff",因此:
// - 驱动能扫进 time.Time列声明为 DATETIME 时)
// - 与老数据(秒精度)的文本比较依然正确:前缀相同时短的排前面,
// 而 ":31" 确实早于 ":31.767000"
sqlite.MustRegisterScalarFunction("now", 0,
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return time.Now().UTC().Format("2006-01-02 15:04:05"), nil
return time.Now().UTC().Format("2006-01-02 15:04:05.000000"), nil
})
}

View File

@ -76,6 +76,10 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
// 会话级往返预算0 = 不限)。旧库默认 0引入预算不应该把已在进行的会话卡死。
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
// 派给该 Agent 的新任务默认多少个来回。
// 旧库也给 20之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
// 不能直接搬过来当单任务预算。
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
}
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。

View File

@ -126,6 +126,10 @@ CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
-- 已存在的库补列(重复运行安全)
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,真正的约束在 sessions.max_rounds 上;这里只提供默认值。
ALTER TABLE agents ADD COLUMN IF NOT EXISTS default_rounds INTEGER NOT NULL DEFAULT 20;
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;

View File

@ -3,6 +3,14 @@
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- 默认值不用 CURRENT_TIMESTAMP它只有【秒】精度同一秒内插入的多行
-- 按 created_at 排序结果不确定,
-- 「会话里最早/最后那封邮件」都会取错行
-- (实测同秒插 5 封,排出来的顺序是乱的)。
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
-- 直接决定 UI 上「最新进展」显示哪一封。
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
@ -16,7 +24,7 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
last_login DATETIME,
-- 权限边界:空数组 = 不限
@ -28,7 +36,7 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS user_sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
expires_at DATETIME NOT NULL,
user_agent TEXT DEFAULT ''
);
@ -44,10 +52,19 @@ CREATE TABLE IF NOT EXISTS agents (
workspaces TEXT NOT NULL DEFAULT '[]',
platform TEXT NOT NULL DEFAULT 'pi',
status TEXT NOT NULL DEFAULT 'offline',
max_rounds INTEGER NOT NULL DEFAULT 10,
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上;
-- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。
default_rounds INTEGER NOT NULL DEFAULT 20,
-- max_rounds / used_rounds 是历史遗留的「终身额度」。
-- 终身额度是错的工具:跑满就得管理员手工重置才能再干活,
-- 而 Agent 是长期在线的。现已降级为纯统计used_rounds 只累加、不拦请求),
-- max_rounds 保留列但不再参与判断。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
@ -57,8 +74,8 @@ CREATE TABLE IF NOT EXISTS sessions (
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
owner_user_id TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
rename_dismissed TEXT,
@ -112,7 +129,7 @@ CREATE TABLE IF NOT EXISTS mails (
permission_result TEXT,
status TEXT NOT NULL DEFAULT 'unread',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
hop_limit INTEGER DEFAULT 5,
@ -141,7 +158,7 @@ CREATE TABLE IF NOT EXISTS permission_requests (
context TEXT DEFAULT '',
result TEXT,
decided_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
@ -167,7 +184,7 @@ CREATE TABLE IF NOT EXISTS agent_keys (
expires_at DATETIME,
used_at DATETIME,
created_by TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
@ -181,7 +198,7 @@ CREATE TABLE IF NOT EXISTS user_keys (
key_type TEXT NOT NULL DEFAULT 'permanent',
expires_at DATETIME,
used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
@ -212,7 +229,7 @@ CREATE TABLE IF NOT EXISTS relayed_mails (
relay_key TEXT NOT NULL,
mail_id TEXT REFERENCES mails(mail_id),
kind TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
PRIMARY KEY (agent_name, relay_key)
);
@ -232,7 +249,7 @@ CREATE TABLE IF NOT EXISTS attachments (
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
sha256 TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);

View File

@ -111,18 +111,20 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
return
}
// 心跳回传配额:插件据此把剩余次数注入 Agent 上下文,
// 让它在配额耗尽前主动发总结,而不是撞到 403 才发现。
quota, qErr := repo.GetQuota(r.Context(), agentName)
if qErr != nil {
// 配额读不到不影响心跳本身,降级为不限额
quota = repo.Quota{AgentName: agentName, Unlimited: true, Remaining: -1}
// 心跳回传该 Agent 的累计统计与新任务默认预算。
//
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent
// 剩余往返随每次发信响应budget_remaining回传在那里才有意义。
stats, sErr := repo.GetAgentStats(r.Context(), agentName)
if sErr != nil {
// 统计读不到不影响心跳本身
stats = repo.AgentStats{AgentName: agentName}
}
JSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"pending_mails": pending,
"quota": quota,
"stats": stats,
})
}

View File

@ -356,12 +356,31 @@ func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []u
// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
//
// 一批邮件走一次查询:逐封调 ListAttachmentsFor 是 N+1
// 一个 200 封的会话打开一次要打 200 次库。
func fillAttachments(r *http.Request, mails ...*models.Mail) {
ids := make([]uuid.UUID, 0, len(mails))
for _, m := range mails {
if m != nil {
ids = append(ids, m.ID)
}
}
if len(ids) == 0 {
return
}
byMail, err := repo.ListAttachmentsForMails(r.Context(), ids)
if err != nil {
return
}
for _, m := range mails {
if m == nil {
continue
}
if as, err := repo.ListAttachmentsFor(r.Context(), m.ID); err == nil {
// 没有附件的邮件保持 nilAttachments 带 omitempty
// 填空切片只会给每封邮件的 JSON 加一个 "attachments":[]
if as := byMail[m.ID]; len(as) > 0 {
m.Attachments = as
}
}

View File

@ -123,24 +123,27 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
subject := forwardSubject(req.Subject, src.Subject)
// 转发按目标地址寻址,不带 reply_to它是一条新线索不该并进原会话
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias)
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
if isAgent {
quota, qErr := repo.ConsumeQuota(r.Context(), actor)
if errors.Is(qErr, repo.ErrQuotaExhausted) {
// 转发也是一次主动发信,扣【目标会话】的往返预算。
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。
budget, bErr := repo.ConsumeSessionBudget(r.Context(), sessionID)
if errors.Is(bErr, repo.ErrSessionBudgetExhausted) {
Error(w, http.StatusForbidden, fmt.Sprintf(
"发信配额已用尽(%d/%d。请先向人类发送最终总结,或联系管理员重置配额。",
quota.Used, quota.Max))
"目标会话的往返预算已用尽(%d/%d。请让人在对话页调高该会话的预算。",
budget.Used, budget.Max))
return
}
if qErr != nil {
Error(w, http.StatusInternalServerError, "Failed to check quota")
if bErr != nil {
Error(w, http.StatusInternalServerError, "Failed to check session budget")
return
}
repo.BumpSentCount(r.Context(), actor)
} else if user != nil {
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
}
@ -207,26 +210,35 @@ func MeForwardMail(w http.ResponseWriter, r *http.Request) {
doForward(w, r, mailID, user.Username, "", false)
}
// ---------- 配额管理(管理员) ----------
// ---------- Agent 默认预算与统计(管理员) ----------
// GET /api/v1/admin/quotas
//
// 路径沿用 quotas兼容已部署的前端但语义已变
// 返回的是【新任务默认预算 + 累计统计】,而不是会拦请求的终身额度。
// 真正的额度在每条会话上GET /sessions/{id}/budget
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
quotas, err := repo.ListQuotas(r.Context())
stats, err := repo.ListAgentStats(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list quotas")
Error(w, http.StatusInternalServerError, "Failed to list agent stats")
return
}
JSON(w, http.StatusOK, map[string]any{"quotas": quotas})
JSON(w, http.StatusOK, map[string]any{"quotas": stats})
}
type setQuotaRequest struct {
// MaxRounds 发信配额上限;0 = 不限
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限
DefaultRounds *int `json:"default_rounds"`
// MaxRounds 是 DefaultRounds 的旧字段名,保留兼容:
// 已部署的前端与脚本不应该因为改名就难以察觉地失效。
MaxRounds *int `json:"max_rounds"`
// Reset 为 true 时把已用次数归零
Reset bool `json:"reset"`
}
// PUT /api/v1/admin/quotas/{name}
//
// 只能改【新任务默认预算】。不再接受 reset
// 累计发信数是观测数据,不拦任何请求,归零它只会销毁历史。
// 要给某个卡住的任务加额度,去那条会话的对话页改预算。
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
@ -239,26 +251,23 @@ func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.MaxRounds == nil && !req.Reset {
Error(w, http.StatusBadRequest, "需要 max_rounds 或 reset 之一")
n := req.DefaultRounds
if n == nil {
n = req.MaxRounds // 兼容旧字段名
}
if n == nil {
Error(w, http.StatusBadRequest, "需要 default_rounds")
return
}
if *n < 0 {
Error(w, http.StatusBadRequest, "default_rounds 不能为负")
return
}
var (
q repo.Quota
err error
)
if req.MaxRounds != nil {
if q, err = repo.SetQuota(r.Context(), name, *req.MaxRounds); err != nil {
Error(w, http.StatusNotFound, err.Error())
return
}
st, err := repo.SetDefaultRounds(r.Context(), name, *n)
if err != nil {
Error(w, http.StatusNotFound, err.Error())
return
}
if req.Reset {
if q, err = repo.ResetQuota(r.Context(), name); err != nil {
Error(w, http.StatusNotFound, err.Error())
return
}
}
JSON(w, http.StatusOK, map[string]any{"quota": q})
JSON(w, http.StatusOK, map[string]any{"quota": st})
}

View File

@ -39,6 +39,11 @@ func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, m
func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} }
func errConflict(msg string) error { return httpError{http.StatusConflict, msg} }
// errRateLimited 用于新建会话速率限制。用 429 而不是 403
// 前者表示「稍后再来」,后者表示「你没这个权限」——语义完全不同,
// 客户端据此决定是重试还是放弃。
func errRateLimited(msg string) error { return httpError{http.StatusTooManyRequests, msg} }
// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。
// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。
func writeKeyErr(w http.ResponseWriter, err error) {
@ -126,3 +131,12 @@ func emptySlice[T any](s []T) []T {
}
return s
}
// agentLimiterKey 把「这是不是 Agent 发起的」翻译成速率限制的键。
// 人类返回空串 = 不限速(手工操作的频率天然受限)。
func agentLimiterKey(isAgent bool, actor string) string {
if isAgent {
return actor
}
return ""
}

View File

@ -47,7 +47,11 @@ type sendMailRequest struct {
//
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string) (uuid.UUID, *uuid.UUID, error) {
//
// byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制:
// 往返预算按会话计Agent 用 .new 开一串会话就等于绕过预算。
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) {
if replyTo != "" {
replyID, err := uuid.Parse(replyTo)
if err != nil {
@ -76,10 +80,22 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
}
aliasPtr = &a
}
// Agent 主动开新线索要过速率限制
if ok, retry := repo.AllowNewSession(byAgent); !ok {
return uuid.Nil, nil, errRateLimited(fmt.Sprintf(
"新建会话过于频繁1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
repo.SessionRateLimit(), retry))
}
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject)
if err != nil {
// 建失败要把名额还回去:那次新建实际上没有发生
repo.ReleaseNewSession(byAgent)
}
return id, nil, err
case models.SessionDefault:
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
// 不构成暴开的手段,因此不计入速率限制。
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
return id, nil, err
@ -132,7 +148,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias)
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
@ -149,7 +165,6 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
var quota repo.Quota
var budget repo.SessionBudget
if relay != "" {
// 先占幂等键。重复则说明这条上游消息已经转过,
@ -167,19 +182,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
// 仅读快照用于回传,不扣任何一层
quota, _ = repo.GetQuota(r.Context(), agentName)
// 仅读快照用于回传,不扣预算
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
} else {
// 两层都要过:会话预算管「这件事值得多少个来回」,
// Agent 全局配额管「这个 Agent 总共能发多少」。
// 先扣会话、后扣全局;全局拦下时把会话那次退回去 ——
// 那次往返实际上没有发生,不能白掉一格。
// 额度只看【本任务】的往返预算。
//
// 不再叠一层 Agent 终身额度:那种额度跑满后要管理员手工重置才能再干活,
// 而 Agent 是长期在线的。防止 Agent 用 .new 开一串新会话绕过预算,
// 靠的是新建会话速率限制resolveTarget 里)。
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
Error(w, http.StatusForbidden, fmt.Sprintf(
"本会话的往返预算已用尽(%d/%d。自动转发的总结与权限询问不占预算"+
"若需继续主动发信,请让人在对话页调高本会话的预算。",
"本任务的往返预算已用尽(%d/%d。自动转发的总结与权限询问不占预算"+
"若需继续主动发信,请让人在对话页调高本任务的预算。",
budget.Used, budget.Max))
return
}
@ -187,21 +202,8 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to check session budget")
return
}
quota, err = repo.ConsumeQuota(r.Context(), agentName)
if errors.Is(err, repo.ErrQuotaExhausted) {
repo.RefundSessionBudget(r.Context(), sessionID)
Error(w, http.StatusForbidden, fmt.Sprintf(
"Agent 全局发信配额已用尽(%d/%d。插件代劳转发的权限询问与最终总结不占配额"+
"若需继续主动发信请联系管理员重置配额。",
quota.Used, quota.Max))
return
}
if err != nil {
repo.RefundSessionBudget(r.Context(), sessionID)
Error(w, http.StatusInternalServerError, "Failed to check quota")
return
}
// 纯统计,不拦请求;写失败也不该让邮件发不出去
repo.BumpSentCount(r.Context(), agentName)
}
// Agent 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
@ -237,27 +239,22 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject)
// 回传会话别名与剩余配额,让发件方知道后续用什么地址续谈、还能发几封
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
resp := map[string]any{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
}
if !quota.Unlimited {
resp["quota_remaining"] = quota.Remaining
resp["quota_used"] = quota.Used
resp["quota_max"] = quota.Max
}
// 会话预算是【本任务】的剩余往返Agent 更应该看这个而不是全局配额
// 预算属于【本任务】,不限时不回传 —— 多给一个 -1 只会让插件去判断哪个值是哨兵
if !budget.Unlimited {
resp["budget_remaining"] = budget.Remaining
resp["budget_used"] = budget.Used
resp["budget_max"] = budget.Max
}
if relay != "" {
// 告知本次未扣配额,否则插件看到 quota_remaining 没变会以为数据错了
// 告知本次未扣预算,否则插件看到 budget_remaining 没变会以为数据错了
resp["relay"] = relay
resp["quota_charged"] = false
resp["budget_charged"] = false
}
if proposal != nil {
// 回传规范化后的别名Agent 提的名字可能含非法字符被改写过,
@ -333,9 +330,11 @@ func GetInbox(w http.ResponseWriter, r *http.Request) {
return
}
// Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
fillAttachments(r, &mails[i])
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), agentName)
JSON(w, http.StatusOK, map[string]interface{}{
@ -419,3 +418,74 @@ func parseInt(s string) (int, error) {
}
return n, nil
}
type markReadRequest struct {
// MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。
MailIDs []string `json:"mail_ids"`
}
// POST /api/v1/mail/read —— Agent 侧批量标记已读
//
// 为什么需要它Agent 读完 read_inbox 后没有任何办法把邮件标掉,
// 于是每次拉收件箱都把同一批旧邮件重新捞出来 —— 处理过的信和新来的信混在一起,
// 模型分不清哪封该回。心跳里的未读数也永远只增不减。
//
// 鉴权写进 UPDATE 的 WHERE 而不是先查后改:不是发给自己的邮件根本改不动,
// 既省一次查询,也没有「查完到改之间邮件被转走」的时间窗。
func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req markReadRequest
// 允许空 body`POST /mail/read` 不带任何内容 = 全部标掉
if r.ContentLength > 0 {
if err := Decode(r, &req); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON")
return
}
}
// 不给 id 就把收件箱里全部未读标掉。
// 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。
if len(req.MailIDs) == 0 {
n, err := repo.MarkAllInboxReadFor(r.Context(), agentName)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read")
return
}
JSON(w, http.StatusOK, map[string]any{"status": "read", "marked": n, "scope": "all"})
return
}
const maxBatch = 200
if len(req.MailIDs) > maxBatch {
Error(w, http.StatusBadRequest, fmt.Sprintf("一次最多标记 %d 封", maxBatch))
return
}
ids := make([]uuid.UUID, 0, len(req.MailIDs))
for _, s := range req.MailIDs {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
Error(w, http.StatusBadRequest, "非法的 mail_id: "+s)
return
}
ids = append(ids, id)
}
n, err := repo.MarkMailsReadFor(r.Context(), agentName, ids)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to mark read")
return
}
// 不因为「有些 id 不是发给你的」而报错:那些 id 只是没被标掉。
// 报错会让整批失败,而 Agent 通常是把上一轮列出的 id 原样传回来,
// 其中可能混着已读的(幂等)——那不该是错误。
JSON(w, http.StatusOK, map[string]any{
"status": "read",
"marked": n,
"requested": len(ids),
})
}

View File

@ -77,7 +77,7 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias)
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
@ -85,14 +85,23 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
// 人类发起的会话归属于该用户
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
// 新建会话时接受往返预算。只在新建时设:续谈已有会话若也接受这个字段,
// 新建会话时往返预算。只在新建时设:续谈已有会话若也接受这个字段,
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
if req.MaxRounds != nil && parentMailID == nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
//
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
// 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级。
if parentMailID == nil {
rounds := 0
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
} else {
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds); err != nil {
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
@ -154,9 +163,11 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) {
return
}
// 列表页要显示附件图标与下载入口
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
fillAttachments(r, &mails[i])
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
total, _ := repo.CountUnread(r.Context(), user.Username)
JSON(w, http.StatusOK, map[string]interface{}{
@ -182,9 +193,11 @@ func MeGetSent(w http.ResponseWriter, r *http.Request) {
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
if err == nil {
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
fillAttachments(r, &mails[i])
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list sent")
@ -224,6 +237,11 @@ func MeGetSessions(w http.ResponseWriter, r *http.Request) {
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
// 往返预算随列表一并返回:预算是【任务】的属性,
// 工作列表上就应当看得见哪些任务快跑满了,
// 而不是点进去一个一个查。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
}
result := make([]SessionOut, 0, len(sessions))
@ -239,6 +257,8 @@ func MeGetSessions(w http.ResponseWriter, r *http.Request) {
UpdatedAt: s.UpdatedAt,
MailCount: s.MailCount,
UnreadCount: unread,
MaxRounds: s.MaxRounds,
UsedRounds: s.UsedRounds,
})
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
@ -52,6 +53,16 @@ func GetSession(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to get session mails")
return
}
// 会话线程要展示附件,逐封填充。
//
// 这里漏掉过前端的会话视图走的是本端点GET /sessions/{id}
// 而不是下面那个 /sessions/{id}/mails —— 后者填了附件但没人调用,
// 于是 Agent 回信里的附件在 UI 上完全不存在。
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
JSON(w, http.StatusOK, map[string]interface{}{
"session": session,
@ -71,9 +82,11 @@ func GetSessionMails(w http.ResponseWriter, r *http.Request) {
return
}
// 会话线程要展示附件,逐封填充
ptrs := make([]*models.Mail, len(mails))
for i := range mails {
fillAttachments(r, &mails[i])
ptrs[i] = &mails[i]
}
fillAttachments(r, ptrs...)
JSON(w, http.StatusOK, map[string]interface{}{
"mails": emptySlice(mails),
})

View File

@ -16,7 +16,12 @@ type Agent struct {
Workspaces []Workspace `json:"workspaces"`
Platform string `json:"platform"`
Status string `json:"status"`
MaxRounds int `json:"max_rounds"`
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回0 = 不限)。
// 真正的额度在每条会话上sessions.max_rounds这里只是默认值。
DefaultRounds int `json:"default_rounds"`
// UsedRounds 是累计发信数,纯统计,不拦请求。
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
// 而 Agent 是长期在线的,所以已降级为观测数据。
UsedRounds int `json:"used_rounds"`
LastSeen *time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`

View File

@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
@ -224,3 +226,46 @@ func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string
`, *a.MailID, name).Scan(&n)
return n > 0, err
}
// ListAttachmentsForMails 批量取多封邮件的附件,返回 mail_id → 附件列表。
//
// 为什么要批量:会话线程与收发件箱都是「一批邮件」,逐封调 ListAttachmentsFor
// 就是 N+1 —— 一个 200 封的会话打开一次要打 200 次库。
// 用 IN (...) 一次取回后在内存里分组。
//
// 占位符手工拼而非用数组参数SQLite 驱动不支持 PG 的 = ANY($1)
// 而这里的元素是已解析的 uuid.UUID不存在注入面。
func ListAttachmentsForMails(ctx context.Context, mailIDs []uuid.UUID) (map[uuid.UUID][]models.Attachment, error) {
out := map[uuid.UUID][]models.Attachment{}
if len(mailIDs) == 0 {
return out, nil
}
ph := make([]string, len(mailIDs))
args := make([]any, len(mailIDs))
for i, id := range mailIDs {
ph[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
rows, err := db.DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments
WHERE mail_id IN (`+strings.Join(ph, ",")+`)
ORDER BY created_at`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
if a.MailID == nil {
continue // WHERE 已排除,只是防御
}
out[*a.MailID] = append(out[*a.MailID], *a)
}
return out, rows.Err()
}

View File

@ -0,0 +1,142 @@
package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// seedClock 给测试数据发严格递增的时间戳。
//
// 不靠挂钟:测试在一个循环里连插几封,很可能落在同一毫秒里,
// 于是「会话里最早/最后那封」的排序由 mail_id随机 UUID决定 —— 结果随机。
// 生产里两封邮件至少隔着一次模型推理,同毫秒撞车不现实;
// 但测试必须确定,所以显式发号。
var seedClock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
func nextSeedTime() string {
seedClock = seedClock.Add(time.Second)
return seedClock.Format("2006-01-02 15:04:05.000")
}
// seedMailIn 在指定会话里插一封邮件,返回其 id。
// 时间戳严格递增,因此调用顺序就是邮件的先后顺序。
func seedMailIn(t *testing.T, sessionID uuid.UUID, from, to, subject string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
subject, body, cc_list, created_at)
VALUES ($1, $2, '', $3, '', $4, 'body', '[]', $5)
RETURNING mail_id
`, sessionID, from, to, subject, nextSeedTime()).Scan(&id)
if err != nil {
t.Fatalf("seed mail: %v", err)
}
return id
}
func seedSessionRow(t *testing.T, alias string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO sessions (from_agent, subject, session_alias)
VALUES ('opencode', 'attach test', $1)
RETURNING session_id
`, alias).Scan(&id)
if err != nil {
t.Fatalf("seed session: %v", err)
}
return id
}
func attach(t *testing.T, mailID uuid.UUID, name, sum string) {
t.Helper()
a, err := CreateAttachment(context.Background(), "admin", name, "text/plain", 3, sum)
if err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := AttachToMail(context.Background(), mailID, []uuid.UUID{a.ID}, "admin"); err != nil {
t.Fatalf("attach: %v", err)
}
}
// ListAttachmentsForMails 存在的理由是消掉 N+1
// 原先每封邮件单独查一次,一个 200 封的会话打开要打 200 次库。
func TestListAttachmentsForMails(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "batch-attach")
m1 := seedMailIn(t, sid, "admin", "opencode", "两个附件")
m2 := seedMailIn(t, sid, "opencode", "admin", "一个附件")
m3 := seedMailIn(t, sid, "admin", "opencode", "没有附件")
attach(t, m1, "a.txt", "sum-a")
attach(t, m1, "b.txt", "sum-b")
attach(t, m2, "c.txt", "sum-c")
got, err := ListAttachmentsForMails(context.Background(),
[]uuid.UUID{m1, m2, m3})
if err != nil {
t.Fatalf("批量查询失败: %v", err)
}
if n := len(got[m1]); n != 2 {
t.Errorf("m1 应有 2 个附件,实际 %d", n)
}
if n := len(got[m2]); n != 1 {
t.Errorf("m2 应有 1 个附件,实际 %d", n)
}
// 无附件的邮件不该出现在 map 里:调用方据此保持 Attachments 为 nil
// 这样带 omitempty 的字段不会给每封邮件的 JSON 白加一个 "attachments":[]
if _, ok := got[m3]; ok {
t.Errorf("m3 无附件却出现在结果里:%#v", got[m3])
}
// 同一封内按 created_at 排序,顺序不能乱
if len(got[m1]) == 2 && got[m1][0].Filename != "a.txt" {
t.Errorf("同一封内应按上传顺序,首个是 %s", got[m1][0].Filename)
}
// 每条都要带回 mail_id否则调用方分不清是谁的
for _, a := range got[m1] {
if a.MailID == nil || *a.MailID != m1 {
t.Errorf("附件 %s 的 mail_id 不对:%v", a.Filename, a.MailID)
}
}
}
// 空输入必须返回空 map 而非 nil调用方直接索引不该 panic。
func TestListAttachmentsForMailsEmpty(t *testing.T) {
setupTestDB(t)
for _, ids := range [][]uuid.UUID{nil, {}} {
got, err := ListAttachmentsForMails(context.Background(), ids)
if err != nil {
t.Fatalf("空输入不该报错: %v", err)
}
if got == nil {
t.Fatal("空输入应返回空 map 而非 nil")
}
if len(got) != 0 {
t.Errorf("空输入应返回空结果,实际 %d 项", len(got))
}
}
}
// 传入不存在的 mail_id 不该报错,只是查不到 —— 调用方可能拿着已删邮件的 id。
func TestListAttachmentsForMailsUnknownID(t *testing.T) {
setupTestDB(t)
got, err := ListAttachmentsForMails(context.Background(),
[]uuid.UUID{uuid.New(), uuid.New()})
if err != nil {
t.Fatalf("未知 id 不该报错: %v", err)
}
if len(got) != 0 {
t.Errorf("未知 id 应查不到,实际 %d 项", len(got))
}
}

View File

@ -0,0 +1,144 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// seedMailTo 造一封给 recipient 的未读邮件,可选带抄送。
func seedMailTo(t *testing.T, recipient string, cc string) uuid.UUID {
t.Helper()
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "sender", "t")
if err != nil {
t.Fatal(err)
}
ccJSON := "[]"
if cc != "" {
ccJSON = `[{"name":"` + cc + `","path":"","session":"","raw":"` + cc + `"}]`
}
var id uuid.UUID
err = db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, cc_list)
VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`,
sid, recipient, ccJSON).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func statusOf(t *testing.T, id uuid.UUID) string {
t.Helper()
var s string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&s); err != nil {
t.Fatal(err)
}
return s
}
func TestMarkMailsReadForOnlyOwnMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
mine := seedMailTo(t, "bot", "")
others := seedMailTo(t, "other", "")
// 一次请求里混着别人的邮件:自己的标掉,别人的动不了。
// 鉴权写在 UPDATE 的 WHERE 里,所以这不是「先查后拒」而是根本改不动。
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{mine, others})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("影响行数 = %d期望 1只有自己那封", n)
}
if statusOf(t, mine) != "read" {
t.Fatal("自己的邮件没被标记")
}
if statusOf(t, others) != "unread" {
t.Fatal("别人的邮件被标记了 —— 鉴权失效")
}
}
// 重复标记是幂等的Agent 通常把上一轮列出的 id 原样传回来,
// 其中混着已读的不该算错误
func TestMarkMailsReadForIsIdempotent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "bot", "")
if n, _ := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id}); n != 1 {
t.Fatalf("首次应标掉 1 封,实际 %d", n)
}
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
if err != nil {
t.Fatalf("重复标记不该报错: %v", err)
}
if n != 0 {
t.Fatalf("重复标记影响行数 = %d期望 0", n)
}
}
// 被抄送的邮件也在收件箱里,也该能标掉
func TestMarkMailsReadForCoversCC(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id := seedMailTo(t, "other", "bot") // 主收件人是 otherbot 被抄送
n, err := MarkMailsReadFor(ctx, "bot", []uuid.UUID{id})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("被抄送的邮件应可标记,影响行数 = %d", n)
}
}
func TestMarkMailsReadForEmptyList(t *testing.T) {
setupTestDB(t)
// 空列表直接返回,不该拼出 `IN ()` 这种非法 SQL
n, err := MarkMailsReadFor(context.Background(), "bot", nil)
if err != nil {
t.Fatalf("空列表不该报错: %v", err)
}
if n != 0 {
t.Fatalf("空列表影响行数 = %d", n)
}
}
func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
a := seedMailTo(t, "bot", "")
b := seedMailTo(t, "bot", "")
others := seedMailTo(t, "other", "")
// 把 b 所在会话归档:那封在收件箱里根本看不到,
// 标掉它只会让「标记了 N 封」与用户看到的对不上
var sid uuid.UUID
db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid)
db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid)
n, err := MarkAllInboxReadFor(ctx, "bot")
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("影响行数 = %d期望 1排除归档会话", n)
}
if statusOf(t, a) != "read" {
t.Fatal("活跃会话里的未读没被标掉")
}
if statusOf(t, b) != "unread" {
t.Fatal("归档会话里的邮件被标掉了")
}
if statusOf(t, others) != "unread" {
t.Fatal("别人的邮件被标掉了")
}
}

View File

@ -13,123 +13,144 @@ import (
// ---------- 配额 ----------
//
// 配额限制的是 Agent「主动发信」的次数不限制收信
// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。
// **配额是任务的属性,不是 Agent 的属性。**
//
// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。
// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。
var ErrQuotaExhausted = errors.New("quota exhausted")
// Quota 是一个 Agent 的配额快照
type Quota struct {
// 真正的约束在 `sessions.max_rounds`(见本文件末尾的「会话级往返预算」):
// 每条会话独立计数,人在派活时给、在对话页里随时调。
//
// `agents` 表这边只剩两样东西:
//
// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回
// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块),
// 默认值分开设才合理。
//
// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。**
// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活,
// 而 Agent 是长期在线的 —— 终身额度是错的工具。
// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。
//
// 防止 Agent 用 `.new` 开一串新会话绕过预算,靠的是**新建会话速率限制**
// (见 sessionRateLimiter而不是终身额度。
// AgentStats 是一个 Agent 的配额默认值与累计统计。
//
// 没有 Remaining / Unlimited 字段:这里不再有「剩余额度」的概念 ——
// 额度属于会话SessionBudget这里只有「新任务默认多少来回」与「一共发了多少信」。
type AgentStats struct {
AgentName string `json:"agent_name"`
Max int `json:"max_rounds"` // 0 = 不限
Used int `json:"used_rounds"`
Remaining int `json:"remaining"` // 不限时为 -1
Unlimited bool `json:"unlimited"`
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限
DefaultRounds int `json:"default_rounds"`
// SentTotal 累计发信数(纯统计,不拦请求)
SentTotal int `json:"sent_total"`
// ActiveSessions 该 Agent 参与的未归档会话数,配合默认值判断设多少合适
ActiveSessions int `json:"active_sessions"`
}
func makeQuota(name string, max, used int) Quota {
q := Quota{AgentName: name, Max: max, Used: used, Unlimited: max <= 0}
if q.Unlimited {
q.Remaining = -1
return q
}
if r := max - used; r > 0 {
q.Remaining = r
}
return q
}
// GetQuota 读取某 Agent 的配额状态。
func GetQuota(ctx context.Context, agentName string) (Quota, error) {
var max, used int
err := db.DB.QueryRowContext(ctx,
`SELECT max_rounds, used_rounds FROM agents WHERE agent_name = $1`, agentName).Scan(&max, &used)
if errors.Is(err, sql.ErrNoRows) {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
}
if err != nil {
return Quota{}, err
}
return makeQuota(agentName, max, used), nil
}
// ConsumeQuota 原子地占用一次发信配额,返回占用后的快照。
// DefaultRoundsFor 读取该 Agent 的新任务默认预算。
//
// 判断与自增必须在同一条 UPDATE 里完成WHERE used_rounds < max_rounds
// 否则并发发信会双双通过检查再各自 +1把配额刷穿
// 配额耗尽时返回 ErrQuotaExhausted同时给出快照供调用方生成提示。
func ConsumeQuota(ctx context.Context, agentName string) (Quota, error) {
tag, err := db.DB.ExecContext(ctx, `
UPDATE agents SET used_rounds = used_rounds + 1
WHERE agent_name = $1
AND (max_rounds <= 0 OR used_rounds < max_rounds)
`, agentName)
if err != nil {
return Quota{}, err
// Agent 不存在时返回全局兜底值而非报错:派活的人不该因为「对方还没注册」
// 就拿不到一个合理的默认预算 —— 邮件本来就支持发给尚未上线的收件人
func DefaultRoundsFor(ctx context.Context, agentName string) int {
var n int
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(default_rounds, 0) FROM agents WHERE agent_name = $1`,
agentName).Scan(&n)
if err != nil || n < 0 {
return fallbackDefaultRounds
}
n, _ := tag.RowsAffected()
if n == 0 {
// 要么 Agent 不存在,要么配额用尽——用快照区分
q, qErr := GetQuota(ctx, agentName)
if qErr != nil {
return Quota{}, qErr
}
return q, ErrQuotaExhausted
}
return GetQuota(ctx, agentName)
return n
}
// SetQuota 设置某 Agent 的配额上限0 = 不限)
func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) {
if max < 0 {
max = 0
// fallbackDefaultRounds 是 Agent 未注册时的兜底默认预算
// 与建表默认值保持一致;改这里要同时改两份 schema。
const fallbackDefaultRounds = 20
// SetDefaultRounds 设置该 Agent 的新任务默认预算0 = 不限)。
func SetDefaultRounds(ctx context.Context, agentName string, n int) (AgentStats, error) {
if n < 0 {
n = 0
}
tag, err := db.DB.ExecContext(ctx,
`UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max)
`UPDATE agents SET default_rounds = $2 WHERE agent_name = $1`, agentName, n)
if err != nil {
return Quota{}, err
return AgentStats{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
if k, _ := tag.RowsAffected(); k == 0 {
return AgentStats{}, fmt.Errorf("agent %q 不存在", agentName)
}
return GetQuota(ctx, agentName)
return GetAgentStats(ctx, agentName)
}
// ResetQuota 把已用次数归零(配额上限不变)
func ResetQuota(ctx context.Context, agentName string) (Quota, error) {
tag, err := db.DB.ExecContext(ctx,
`UPDATE agents SET used_rounds = 0 WHERE agent_name = $1`, agentName)
// GetAgentStats 读取某 Agent 的默认预算与累计统计
func GetAgentStats(ctx context.Context, agentName string) (AgentStats, error) {
var st AgentStats
st.AgentName = agentName
err := db.DB.QueryRowContext(ctx, `
SELECT COALESCE(default_rounds, 0), COALESCE(used_rounds, 0)
FROM agents WHERE agent_name = $1`, agentName,
).Scan(&st.DefaultRounds, &st.SentTotal)
if errors.Is(err, sql.ErrNoRows) {
return AgentStats{}, fmt.Errorf("agent %q 不存在", agentName)
}
if err != nil {
return Quota{}, err
return AgentStats{}, err
}
if n, _ := tag.RowsAffected(); n == 0 {
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
}
return GetQuota(ctx, agentName)
st.ActiveSessions = countActiveSessionsFor(ctx, agentName)
return st, nil
}
// ListQuotas 列出所有 Agent 的配额(管理员视图)
func ListQuotas(ctx context.Context) ([]Quota, error) {
// countActiveSessionsFor 统计该 Agent 参与的未归档会话数
// 查不出来返回 0这只是个展示用的数字不该让整个统计接口失败。
func countActiveSessionsFor(ctx context.Context, agentName string) int {
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(DISTINCT s.session_id)
FROM sessions s
JOIN mails m ON m.session_id = s.session_id
WHERE s.status <> 'archived'
AND (m.from_name = $1 OR m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
`, agentName).Scan(&n)
if err != nil {
return 0
}
return n
}
// BumpSentCount 累加发信统计。
//
// **绝不拦请求**:它是观测数据,不是额度。返回值只有 error
// 而且调用方应当忽略它 —— 统计写失败不该让一封已经该发出的邮件失败。
func BumpSentCount(ctx context.Context, agentName string) {
_, _ = db.DB.ExecContext(ctx,
`UPDATE agents SET used_rounds = COALESCE(used_rounds, 0) + 1 WHERE agent_name = $1`,
agentName)
}
// ListAgentStats 列出所有 Agent 的默认预算与统计(管理员视图)。
func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`)
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0)
FROM agents ORDER BY agent_name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Quota{}
out := []AgentStats{}
for rows.Next() {
var name string
var max, used int
if err := rows.Scan(&name, &max, &used); err != nil {
var st AgentStats
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal); err != nil {
return nil, err
}
out = append(out, makeQuota(name, max, used))
out = append(out, st)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 会话数逐个查Agent 数量是个位数到几十,不值得为它写一个 GROUP BY 的联合查询
for i := range out {
out[i].ActiveSessions = countActiveSessionsFor(ctx, out[i].AgentName)
}
return out, nil
}
// ---------- 转发 ----------

View File

@ -2,10 +2,8 @@ package repo
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"github.com/agentmail/gateway/internal/db"
@ -30,141 +28,95 @@ func setupTestDB(t *testing.T) {
func seedAgent(t *testing.T, name string, max int) {
t.Helper()
_, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, max_rounds) VALUES ($1, 'x', 'test', $2)`,
`INSERT INTO agents (agent_name, secret, platform, default_rounds) VALUES ($1, 'x', 'test', $2)`,
name, max)
if err != nil {
t.Fatalf("seed agent: %v", err)
}
}
func TestConsumeQuotaCountsDown(t *testing.T) {
// default_rounds 是「派给这个 Agent 的新任务默认多少个来回」,
// 不是会拦请求的终身额度 —— 真正的额度在 sessions.max_rounds 上。
func TestDefaultRoundsRoundTrip(t *testing.T) {
setupTestDB(t)
seedAgent(t, "bot", 3)
seedAgent(t, "bot", 0)
ctx := context.Background()
for i := 1; i <= 3; i++ {
q, err := ConsumeQuota(ctx, "bot")
if err != nil {
t.Fatalf("第 %d 次占用失败: %v", i, err)
}
if q.Used != i || q.Remaining != 3-i {
t.Errorf("第 %d 次: used=%d remaining=%d, want used=%d remaining=%d",
i, q.Used, q.Remaining, i, 3-i)
}
st, err := SetDefaultRounds(ctx, "bot", 15)
if err != nil {
t.Fatal(err)
}
if st.DefaultRounds != 15 {
t.Fatalf("default_rounds = %d期望 15", st.DefaultRounds)
}
if got := DefaultRoundsFor(ctx, "bot"); got != 15 {
t.Fatalf("DefaultRoundsFor = %d期望 15", got)
}
q, err := ConsumeQuota(ctx, "bot")
if !errors.Is(err, ErrQuotaExhausted) {
t.Fatalf("第 4 次应耗尽,得到 err=%v", err)
// 负数归一为 0不限而不是造出一个永远发不出信的默认值
if st, err = SetDefaultRounds(ctx, "bot", -3); err != nil {
t.Fatal(err)
}
// 耗尽时仍要给出快照,调用方才能在错误文案里写清 used/max
if q.Used != 3 || q.Max != 3 {
t.Errorf("耗尽时快照不对: %+v", q)
if st.DefaultRounds != 0 {
t.Fatalf("负数应归一为 0实际 %d", st.DefaultRounds)
}
}
func TestConsumeQuotaUnlimited(t *testing.T) {
// 未注册的 Agent 取默认预算时给兜底值而不是报错:
// 派活的人不该因为「对方还没上线」就拿不到一个合理默认值 ——
// 邮件本来就支持发给尚未上线的收件人。
func TestDefaultRoundsForUnknownAgentFallsBack(t *testing.T) {
setupTestDB(t)
seedAgent(t, "free", 0) // 0 = 不限
if got := DefaultRoundsFor(context.Background(), "ghost"); got != fallbackDefaultRounds {
t.Fatalf("未注册 Agent 应回落到 %d实际 %d", fallbackDefaultRounds, got)
}
}
// BumpSentCount 是纯统计:只累加,绝不拦请求,也绝不返回错误
func TestBumpSentCountOnlyCounts(t *testing.T) {
setupTestDB(t)
seedAgent(t, "bot", 0)
ctx := context.Background()
for i := 0; i < 5; i++ {
q, err := ConsumeQuota(ctx, "free")
if err != nil {
t.Fatalf("不限额时不应失败: %v", err)
}
if !q.Unlimited || q.Remaining != -1 {
t.Errorf("不限额快照不对: %+v", q)
}
BumpSentCount(ctx, "bot")
}
}
// 配额的核心保证:并发发信不能把额度刷穿。
// 判断与自增若分成两步(先 SELECT 再 UPDATE并发下两个请求会双双通过检查。
func TestConsumeQuotaConcurrentDoesNotOverdraw(t *testing.T) {
setupTestDB(t)
const limit = 10
seedAgent(t, "racer", limit)
ctx := context.Background()
const attempts = 40
var (
wg sync.WaitGroup
mu sync.Mutex
granted int
)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := ConsumeQuota(ctx, "racer"); err == nil {
mu.Lock()
granted++
mu.Unlock()
}
}()
}
wg.Wait()
if granted != limit {
t.Errorf("并发 %d 次请求在上限 %d 下放行了 %d 次", attempts, limit, granted)
}
q, err := GetQuota(ctx, "racer")
st, err := GetAgentStats(ctx, "bot")
if err != nil {
t.Fatal(err)
}
if q.Used != limit {
t.Errorf("used_rounds = %d应恰好等于上限 %d未刷穿也未少记", q.Used, limit)
if st.SentTotal != 5 {
t.Fatalf("累计发信 = %d期望 5", st.SentTotal)
}
// 不存在的 Agent 也不该 panic 或报错 —— 它只是没有行可更新
BumpSentCount(ctx, "ghost")
}
func TestGetAgentStatsUnknownAgent(t *testing.T) {
setupTestDB(t)
if _, err := GetAgentStats(context.Background(), "ghost"); err == nil {
t.Fatal("不存在的 Agent 应报错")
}
}
func TestSetAndResetQuota(t *testing.T) {
func TestListAgentStats(t *testing.T) {
setupTestDB(t)
seedAgent(t, "bot", 2)
seedAgent(t, "alpha", 0)
seedAgent(t, "beta", 0)
ctx := context.Background()
SetDefaultRounds(ctx, "alpha", 5)
if _, err := ConsumeQuota(ctx, "bot"); err != nil {
t.Fatal(err)
}
q, err := SetQuota(ctx, "bot", 5)
list, err := ListAgentStats(ctx)
if err != nil {
t.Fatal(err)
}
// 调上限不应清掉已用次数
if q.Max != 5 || q.Used != 1 || q.Remaining != 4 {
t.Errorf("SetQuota 后 %+vwant max=5 used=1 remaining=4", q)
if len(list) != 2 {
t.Fatalf("应有 2 个 Agent实际 %d", len(list))
}
q, err = ResetQuota(ctx, "bot")
if err != nil {
t.Fatal(err)
}
if q.Used != 0 || q.Remaining != 5 {
t.Errorf("ResetQuota 后 %+vwant used=0 remaining=5", q)
}
// 负数上限归一为 0不限而不是造出一个永远发不出信的 Agent
if q, err = SetQuota(ctx, "bot", -3); err != nil {
t.Fatal(err)
} else if !q.Unlimited {
t.Errorf("负数上限应视为不限,得到 %+v", q)
}
}
func TestQuotaUnknownAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if _, err := GetQuota(ctx, "ghost"); err == nil {
t.Error("不存在的 Agent 应报错")
}
if _, err := ConsumeQuota(ctx, "ghost"); err == nil {
t.Error("不存在的 Agent 占用配额应报错")
}
if errors.Is(func() error { _, e := ConsumeQuota(ctx, "ghost"); return e }(), ErrQuotaExhausted) {
t.Error("不存在的 Agent 不该被报成「配额耗尽」")
// 按名字排序alpha 在前
if list[0].AgentName != "alpha" || list[0].DefaultRounds != 5 {
t.Fatalf("alpha 的记录不对:%+v", list[0])
}
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
@ -17,6 +18,8 @@ import (
func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error {
wsJSON, _ := json.Marshal(workspaces)
// 注意 DO UPDATE 里【不】碰 default_rounds
// 那是管理员配的值Agent 重启重新注册不应该把它冲回默认。
_, err := db.DB.ExecContext(ctx, `
INSERT INTO agents (agent_name, secret, workspaces, platform, status, last_seen)
VALUES ($1, $2, $3, $4, 'online', NOW())
@ -41,7 +44,10 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
}
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
q := `SELECT agent_id, agent_name, workspaces, platform, status FROM agents`
// 带上 default_rounds前端补全收件人时要显示「派给它的任务默认几个来回」
// 否则人得先去管理员页查一遍才敢派活。
q := `SELECT agent_id, agent_name, workspaces, platform, status,
COALESCE(default_rounds, 0) FROM agents`
args := []any{}
if statusFilter != "" {
q += ` WHERE status = $1`
@ -59,7 +65,8 @@ func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error
for rows.Next() {
var a models.Agent
var wsJSON []byte
if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status); err != nil {
if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status,
&a.DefaultRounds); err != nil {
return nil, err
}
if wsJSON != nil {
@ -177,7 +184,7 @@ func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models
}
sessions = append(sessions, s)
}
return sessions, nil
return sessions, rows.Err()
}
// ---------- Mail ----------
@ -190,9 +197,13 @@ func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUI
ccJSON, _ := json.Marshal(ccList)
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
// created_at 显式给 NOW()SQLite 的 DEFAULT CURRENT_TIMESTAMP 只有秒精度,
// 同秒插入的多封邮件排序不确定(「会话里最早/最后那封」都会取错行)。
// 改 schema 的默认值只对新库生效 —— CREATE TABLE IF NOT EXISTS 不改已存在的表,
// 而 SQLite 没有 ALTER COLUMN因此这里显式传。
`INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
to_name, to_workspace, subject, body, cc_list)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING mail_id`,
to_name, to_workspace, subject, body, cc_list, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) RETURNING mail_id`,
sessionID, parentMailID, fromName, fromWorkspace, toName, toWorkspace, subject, body, ccJSON,
).Scan(&id)
return id, err
@ -203,8 +214,8 @@ func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, to
optsJSON, _ := json.Marshal(options)
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options)
VALUES ($1, $2, $3, $4, $5, 'permission_request', $6) RETURNING mail_id`,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options, created_at)
VALUES ($1, $2, $3, $4, $5, 'permission_request', $6, NOW()) RETURNING mail_id`,
sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON,
).Scan(&id)
return id, err
@ -218,8 +229,8 @@ func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID u
body = fmt.Sprintf("%s\n\n备注: %s", decision, note)
}
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING mail_id`,
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING mail_id`,
sessionID, parentMailID, fromUser, toAgent, "Re: 权限请求 - "+decision, body,
).Scan(&id)
return id, err
@ -284,7 +295,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
q += ` AND m.status = $2`
args = append(args, status)
}
q += ` ORDER BY m.created_at DESC`
q += ` ORDER BY m.created_at DESC, m.mail_id DESC`
if limit > 0 {
q += fmt.Sprintf(` LIMIT %d`, limit)
}
@ -323,7 +334,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
}
mails = append(mails, m)
}
return mails, nil
return mails, rows.Err()
}
func CountUnread(ctx context.Context, agentName string) (int, error) {
@ -348,7 +359,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1
ORDER BY m.created_at ASC`, sessionID)
ORDER BY m.created_at ASC, m.mail_id ASC`, sessionID)
if err != nil {
return nil, err
}
@ -376,7 +387,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
}
mails = append(mails, m)
}
return mails, nil
return mails, rows.Err()
}
// ---------- Permission ----------
@ -678,6 +689,22 @@ type Contact struct {
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
LastActivity time.Time `json:"last_activity"`
// Subject 是会话主题(多由 Agent 平台的模型生成的摘要)。
// 卡片视图要靠它回答「这条线索在干什么」—— 光有 name@path.alias
// 只能看出跟谁在聊,看不出在聊什么。
Subject string `json:"subject"`
// MaxRounds/UsedRounds 是本任务的往返预算0 = 不限)。
// 列表上直接可见,才不用点进每条会话去查哪件事快跑满了。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
// LastFrom/LastPreview 是最后一封邮件的发件人与正文摘要,
// 卡片视图用它显示「最新进展」——列表视图只显示地址时,
// 人必须逐条点开才知道哪条有新动静。
LastFrom string `json:"last_from"`
LastPreview string `json:"last_preview"`
}
// ListContactsFor 按 (agent, path, session) 聚合出联系人清单。
@ -723,6 +750,16 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
)`
}
// 最后一封邮件用关联子查询取,不再 JOIN 一次:
// 两个 JOIN最早一封 + 最新一封)在 SQLite 下要写两段方言分支,
// 而这里每个会话只多两次索引查找idx_mails_session 已有)。
lastMail := `(
SELECT mail_id FROM mails
WHERE session_id = s.session_id
ORDER BY created_at DESC, mail_id DESC
LIMIT 1
)`
rows, err := db.DB.QueryContext(ctx, `
SELECT s.session_id,
COALESCE(NULLIF(m.to_name, 'human'), m.from_name) AS agent_name,
@ -731,7 +768,12 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
s.status,
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id),
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id AND x.status = 'unread'),
s.updated_at
s.updated_at,
s.subject,
COALESCE(s.max_rounds, 0),
COALESCE(s.used_rounds, 0),
COALESCE((SELECT from_name FROM mails WHERE mail_id = `+lastMail+`), ''),
COALESCE((SELECT body FROM mails WHERE mail_id = `+lastMail+`), '')
FROM sessions s`+firstMail+`
WHERE s.status `+op+` 'archived'`+scope+`
ORDER BY s.updated_at DESC
@ -745,16 +787,32 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
for rows.Next() {
var c Contact
if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias,
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity); err != nil {
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity,
&c.Subject, &c.MaxRounds, &c.UsedRounds,
&c.LastFrom, &c.LastPreview); err != nil {
return nil, err
}
c.Address = c.AgentName + "@" + c.Path
if c.SessionAlias != "" {
c.Address += "." + c.SessionAlias
}
// 正文只留一段摘要:卡片上放不下全文,而整个列表带全文可能几百 KB。
// 按 rune 截断而非字节 —— 中文 3 字节/字,裸切会产生 U+FFFD。
c.LastPreview = previewRunes(c.LastPreview, 90)
contacts = append(contacts, c)
}
return contacts, nil
return contacts, rows.Err()
}
// previewRunes 按字符数截断,附省略号。
// 不按字节切:中文一字三字节,裸切会在末尾留半个字符(渲染成 U+FFFD
func previewRunes(s string, n int) string {
s = strings.TrimSpace(s)
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n]) + "…"
}
// ArchiveSession 归档一个会话(邮箱界面不再展示,数据保留)
@ -837,7 +895,7 @@ func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]
out = append(out, alias)
}
}
return out, nil
return out, rows.Err()
}
// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话
@ -850,7 +908,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.from_name = $1 AND s.status <> 'archived'
ORDER BY m.created_at DESC
ORDER BY m.created_at DESC, m.mail_id DESC
LIMIT $2
`, fromName, limit)
if err != nil {
@ -885,7 +943,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
}
mails = append(mails, m)
}
return mails, nil
return mails, rows.Err()
}
// ListPendingPermissionsFor 列出待决权限请求forUser 非空时只列发给该用户的
@ -956,13 +1014,16 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
sessions := []models.Session{}
for rows.Next() {
var s models.Session
// 列数必须与上面的 SELECT 一一对应 —— 预算两列曾经只加进了查询而没加进这里,
// 结果 /me/sessions 整个 500联系人栅拉不到任何数据。
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, &s.MailCount); err != nil {
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, nil
return sessions, rows.Err()
}
// CountUnreadInSession 统计某人在某会话内的未读数(含被抄送)
@ -1044,3 +1105,57 @@ func DismissRenameProposal(ctx context.Context, sessionID uuid.UUID, alias strin
alias, sessionID)
return err
}
// MarkMailsReadFor 把一批邮件标记为某收件人已读,返回实际影响的行数。
//
// **鉴权写进 WHERE 而不是先查后改**`to_name = $1 OR cc 含 $1` 直接放在
// UPDATE 条件里,于是「不是发给我的邮件」根本改不动 —— 既省掉一次查询,
// 也没有「查完到改之间邮件被转走」的时间窗。
//
// 已经是 read 的不计入影响行数(`status = 'unread'` 条件),
// 调用方据此知道这次真正标掉了几封。
func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (int, error) {
if len(ids) == 0 {
return 0, nil
}
// IN 子句的占位符按方言编号SQLite 与 PG 都认 $N
// 不用一条条 UPDATE一次网络往返 + 一次事务SQLite 单写者下差别明显。
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, recipient)
for i, id := range ids {
ph[i] = fmt.Sprintf("$%d", i+2)
args = append(args, id)
}
res, err := db.DB.ExecContext(ctx, `
UPDATE mails SET status = 'read'
WHERE mail_id IN (`+strings.Join(ph, ",")+`)
AND status = 'unread'
AND (to_name = $1 OR `+db.CCHas("cc_list", 1)+`)
`, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// MarkAllInboxReadFor 把某收件人收件箱里全部未读标为已读,返回影响行数。
//
// 排除已归档会话:那些邮件在收件箱里根本看不到,
// 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。
func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
res, err := db.DB.ExecContext(ctx, `
UPDATE mails SET status = 'read'
WHERE status = 'unread'
AND (to_name = $1 OR `+db.CCHas("cc_list", 1)+`)
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')
`, recipient)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}

View File

@ -0,0 +1,228 @@
package repo
import (
"context"
"fmt"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// ListSessionsFor 的 Scan 列数必须与 SELECT 一致。
//
// 这个测试存在的理由预算两列max_rounds/used_rounds加进了 SELECT 却忘了加进
// Scan于是 /me/sessions 整个 500 —— 联系人栏一条数据都拉不到,
// 而错误信息只是 "Failed to list sessions",看不出是列数不匹配。
// 列数错位是纯结构问题,一个最小用例就能钉住。
func TestListSessionsForScanMatchesSelect(t *testing.T) {
setupTestDB(t)
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('alice', 'Alice', 'x', 'user')`); err != nil {
t.Fatalf("seed user: %v", err)
}
sid := seedSessionRow(t, "list-scan")
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET max_rounds = 7, used_rounds = 3 WHERE session_id = $1`,
sid); err != nil {
t.Fatalf("set budget: %v", err)
}
seedMailIn(t, sid, "alice", "opencode", "hello")
// 无过滤(管理员 all=true 走这条)
all, err := ListSessionsFor(context.Background(), "", 50)
if err != nil {
t.Fatalf("列出全部会话失败: %v", err)
}
if len(all) != 1 {
t.Fatalf("应有 1 个会话,实际 %d", len(all))
}
// 预算两列要真的读出来,不是零值
if all[0].MaxRounds != 7 || all[0].UsedRounds != 3 {
t.Errorf("预算未读出max=%d used=%d期望 7/3",
all[0].MaxRounds, all[0].UsedRounds)
}
if all[0].MailCount != 1 {
t.Errorf("邮件数应为 1实际 %d —— 列顺序可能错位", all[0].MailCount)
}
// 带用户过滤普通用户走这条SQL 分支不同,要分别验)
mine, err := ListSessionsFor(context.Background(), "alice", 50)
if err != nil {
t.Fatalf("列出自己的会话失败: %v", err)
}
if len(mine) != 1 {
t.Fatalf("alice 参与过该会话,应能看到,实际 %d 个", len(mine))
}
if mine[0].MaxRounds != 7 || mine[0].MailCount != 1 {
t.Errorf("过滤分支的列顺序错位:%+v", mine[0])
}
// 与自己无关的人看不到
other, err := ListSessionsFor(context.Background(), "bob", 50)
if err != nil {
t.Fatalf("列出 bob 的会话失败: %v", err)
}
if len(other) != 0 {
t.Errorf("bob 未参与该会话,不该看到,实际 %d 个", len(other))
}
// 归档会话不出现在列表里
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil {
t.Fatalf("archive: %v", err)
}
after, err := ListSessionsFor(context.Background(), "", 50)
if err != nil {
t.Fatalf("归档后列出失败: %v", err)
}
if len(after) != 0 {
t.Errorf("归档会话不该出现在列表里,实际 %d 个", len(after))
}
}
// 工作列表卡片视图需要「这条线索在干什么 / 还剩几个来回 / 最新进展是什么」,
// 这些都从 ListContactsFor 一次取回 —— 否则卡片要为每条会话再打一次库。
func TestListContactsForCardFields(t *testing.T) {
setupTestDB(t)
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('alice', 'Alice', 'x', 'user')`); err != nil {
t.Fatalf("seed user: %v", err)
}
sid := seedSessionRow(t, "card-fields")
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET subject = '缓存层选型评估', max_rounds = 5, used_rounds = 2
WHERE session_id = $1`, sid); err != nil {
t.Fatalf("set session: %v", err)
}
// 三封:最早一封决定联系人身份,最后一封决定「最新进展」
seedMailIn(t, sid, "alice", "opencode", "第一封")
seedMailIn(t, sid, "opencode", "alice", "第二封")
last := seedMailIn(t, sid, "opencode", "alice", "第三封")
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE mails SET body = '已经跑完压测Redis 方案在这个负载下明显更稳。'
WHERE mail_id = $1`, last); err != nil {
t.Fatalf("set body: %v", err)
}
got, err := ListContactsFor(context.Background(), "alice", false)
if err != nil {
t.Fatalf("列出联系人失败: %v", err)
}
if len(got) != 1 {
t.Fatalf("应有 1 个联系人,实际 %d", len(got))
}
c := got[0]
if c.Subject != "缓存层选型评估" {
t.Errorf("主题未带回:%q", c.Subject)
}
if c.MaxRounds != 5 || c.UsedRounds != 2 {
t.Errorf("预算未带回:%d/%d期望 5/2", c.UsedRounds, c.MaxRounds)
}
// 最新进展取的是【最后】一封,不是第一封
if c.LastFrom != "opencode" {
t.Errorf("最新发件人应为 opencode实际 %q", c.LastFrom)
}
if !strings.Contains(c.LastPreview, "Redis 方案") {
t.Errorf("最新摘要应来自最后一封,实际 %q", c.LastPreview)
}
// 联系人身份仍取最早一封的对端
if c.AgentName != "opencode" {
t.Errorf("联系人应为 opencode实际 %q", c.AgentName)
}
if c.MailCount != 3 {
t.Errorf("邮件数应为 3实际 %d —— 列顺序可能错位", c.MailCount)
}
if c.Address != "opencode@.card-fields" && !strings.HasSuffix(c.Address, ".card-fields") {
t.Errorf("地址应带会话别名,实际 %q", c.Address)
}
}
// 摘要按字符截断,不按字节 —— 中文一字三字节,裸切会留半个字符。
func TestPreviewRunes(t *testing.T) {
cases := []struct {
in string
n int
want string
}{
{"短文本", 10, "短文本"},
{" 两边有空白 ", 10, "两边有空白"},
{"", 5, ""},
{"一二三四五六", 3, "一二三…"},
{"abcdefgh", 3, "abc…"},
}
for _, c := range cases {
if got := previewRunes(c.in, c.n); got != c.want {
t.Errorf("previewRunes(%q, %d) = %q期望 %q", c.in, c.n, got, c.want)
}
}
// 截断结果必须是合法 UTF-8不含替换字符
long := strings.Repeat("汉字", 200)
got := previewRunes(long, 90)
if strings.ContainsRune(got, '\uFFFD') {
t.Error("截断产生了 U+FFFD说明按字节切了")
}
if n := len([]rune(got)); n != 91 { // 90 + 省略号
t.Errorf("截断后应为 90 字符 + 省略号,实际 %d 字符", n)
}
}
// 时间戳精度回归SQLite 的 CURRENT_TIMESTAMP 只有秒,同秒插入的多行排序不确定,
// 「会话里最早那封」(决定联系人身份)与「最后那封」(决定最新进展)都会取错。
// NOW() 现在返回毫秒精度,且 mails 的 INSERT 显式传它 —— 这两点都要钉住。
func TestMailTimestampSubSecond(t *testing.T) {
setupTestDB(t)
sid := seedSessionRow(t, "ts-precision")
// 连续插 8 封(不显式给时间戳,走 CreateMail 里的 NOW()
ids := make([]uuid.UUID, 0, 8)
for i := 0; i < 8; i++ {
id, err := CreateMail(context.Background(), sid, nil,
"alice", "", "opencode", "", fmt.Sprintf("第%d封", i), "body", nil)
if err != nil {
t.Fatalf("创建邮件 %d 失败: %v", i, err)
}
ids = append(ids, id)
}
// 至少要出现亚秒差异,否则说明 NOW() 又退回秒精度
var distinct int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(DISTINCT created_at) FROM mails WHERE session_id = $1`,
sid).Scan(&distinct); err != nil {
t.Fatalf("统计不同时间戳失败: %v", err)
}
if distinct < 2 {
var sample string
db.DB.QueryRowContext(context.Background(),
`SELECT CAST(created_at AS TEXT) FROM mails WHERE session_id = $1 LIMIT 1`,
sid).Scan(&sample)
t.Fatalf("8 封邮件只有 %d 个不同时间戳(样例 %q—— NOW() 精度不足,"+
"同秒邮件的先后顺序会由随机 UUID 决定", distinct, sample)
}
// GetSessionMails 按时间升序,顺序必须与插入顺序一致
got, err := GetSessionMails(context.Background(), sid)
if err != nil {
t.Fatalf("取会话邮件失败: %v", err)
}
if len(got) != len(ids) {
t.Fatalf("应有 %d 封,实际 %d", len(ids), len(got))
}
for i, m := range got {
if m.ID != ids[i] {
t.Errorf("第 %d 封顺序错位:期望 %s实际 %s主题 %q",
i, ids[i], m.ID, m.Subject)
}
}
}

View File

@ -0,0 +1,139 @@
package repo
import (
"errors"
"sync"
"time"
)
// ---------- 新建会话速率限制 ----------
//
// 会话往返预算sessions.max_rounds管住了「一条线索能来回多少次」
// 但 Agent 仍可以用 `name@path.new` 开一串新会话,每条都是全新预算 ——
// 预算就被绕过了。
//
// 为什么用速率限制而不是「终身额度」:
// 终身额度跑满后要管理员手工重置才能再干活,而 Agent 是长期在线的 ——
// 那是把一次性资源的模型套在长期服务上。速率限制只压住「短时间内暴开」这个
// 真正的滥用形态,过一个窗口自动恢复,无需人工介入。
//
// 为什么不禁止 Agent 主动开新会话:那会堵死 Agent 之间的主动协作
//A 发现问题主动找 B而这正是这个平台存在的意义。
// ErrSessionRateLimited 表示该 Agent 短时间内新建会话过多。
var ErrSessionRateLimited = errors.New("session creation rate limited")
const (
// sessionRateWindow 是滑动窗口长度
sessionRateWindow = time.Hour
// sessionRateLimit 是窗口内允许新建的会话数。
//
// 取 20正常协作里 Agent 一小时开二十条新线索已经很多了;
// 真到了这个量级,更可能是循环而不是在干活。
sessionRateLimit = 20
)
// sessionRateLimiter 记录每个 Agent 新建会话的时间戳。
//
// 进程内内存计数与登录限速handler/ratelimit.go同一取舍
// 单实例部署下够用;多实例时各自计数,等效上限变成 N 倍 ——
// 那时应当换成数据库计数或 Redis。这个限制记在 README 的已知取舍里。
type sessionRateLimiter struct {
mu sync.Mutex
marks map[string][]time.Time
}
var sessionLimiter = &sessionRateLimiter{marks: make(map[string][]time.Time)}
// Allow 判断是否允许新建,允许则记账。
//
// 判断与记账在同一把锁里:分开的话并发请求会双双通过检查,把上限刷穿 ——
// 与配额那条 UPDATE 同样的道理。
func (l *sessionRateLimiter) Allow(name string) (bool, int) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-sessionRateWindow)
kept := l.marks[name][:0]
for _, t := range l.marks[name] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
l.marks[name] = kept
if len(kept) >= sessionRateLimit {
// 最早那条何时过期 = 何时能再开一条
retry := int(kept[0].Add(sessionRateWindow).Sub(now).Seconds()) + 1
if retry < 1 {
retry = 1
}
return false, retry
}
l.marks[name] = append(l.marks[name], now)
return true, 0
}
// Release 撤销一次记账。
//
// 允许之后建会话失败时必须还回去,否则那次没发生的新建也占了名额。
func (l *sessionRateLimiter) Release(name string) {
l.mu.Lock()
defer l.mu.Unlock()
m := l.marks[name]
if len(m) > 0 {
l.marks[name] = m[:len(m)-1]
}
}
// AllowNewSession 供 handler 调用Agent 新建会话前先过速率限制。
//
// 第二个返回值是建议的重试等待秒数(供 Retry-After 使用)。
// 人类用户不走这条路径 —— 人手工点「新建邮件」的频率天然受限,
// 给它加限制只会在批量派活时误伤。
func AllowNewSession(agentName string) (bool, int) {
if agentName == "" {
return true, 0
}
return sessionLimiter.Allow(agentName)
}
// ReleaseNewSession 建会话失败后归还名额。
func ReleaseNewSession(agentName string) {
if agentName == "" {
return
}
sessionLimiter.Release(agentName)
}
// 定期清理空闲 Agent 的记录,避免 map 随 Agent 名无限增长。
func init() {
go func() {
t := time.NewTicker(sessionRateWindow)
defer t.Stop()
for range t.C {
sessionLimiter.mu.Lock()
cutoff := time.Now().Add(-sessionRateWindow)
for name, marks := range sessionLimiter.marks {
fresh := marks[:0]
for _, ts := range marks {
if ts.After(cutoff) {
fresh = append(fresh, ts)
}
}
if len(fresh) == 0 {
delete(sessionLimiter.marks, name)
} else {
sessionLimiter.marks[name] = fresh
}
}
sessionLimiter.mu.Unlock()
}
}()
}
// SessionRateLimit 暴露窗口内的新建上限,供错误文案使用。
// 不导出常量本身:外部只该读它,不该改它。
func SessionRateLimit() int { return sessionRateLimit }

View File

@ -0,0 +1,117 @@
package repo
import (
"sync"
"testing"
"time"
)
// 每个测试用独立的 limiter避免相互污染全局那个是进程级的
func newLimiter() *sessionRateLimiter {
return &sessionRateLimiter{marks: make(map[string][]time.Time)}
}
func TestSessionRateAllowsUpToLimit(t *testing.T) {
l := newLimiter()
for i := 1; i <= sessionRateLimit; i++ {
if ok, _ := l.Allow("bot"); !ok {
t.Fatalf("第 %d 次应放行(上限 %d", i, sessionRateLimit)
}
}
ok, retry := l.Allow("bot")
if ok {
t.Fatal("超过上限应拦下")
}
if retry < 1 {
t.Fatalf("应给出正的重试等待秒数,实际 %d", retry)
}
if retry > int(sessionRateWindow.Seconds())+1 {
t.Fatalf("重试等待 %d 秒超过了窗口长度", retry)
}
}
// 不同 Agent 各自计数,一个刷满不该影响另一个
func TestSessionRateIsPerAgent(t *testing.T) {
l := newLimiter()
for i := 0; i < sessionRateLimit; i++ {
l.Allow("busy")
}
if ok, _ := l.Allow("busy"); ok {
t.Fatal("busy 应已被拦")
}
if ok, _ := l.Allow("idle"); !ok {
t.Fatal("另一个 Agent 不该被牵连")
}
}
// 判断与记账必须在同一把锁里,否则并发请求双双通过检查把上限刷穿
func TestSessionRateConcurrentDoesNotOverrun(t *testing.T) {
l := newLimiter()
var wg sync.WaitGroup
var mu sync.Mutex
passed := 0
for i := 0; i < sessionRateLimit*4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if ok, _ := l.Allow("bot"); ok {
mu.Lock()
passed++
mu.Unlock()
}
}()
}
wg.Wait()
if passed != sessionRateLimit {
t.Fatalf("%d 并发下放行 %d 次,期望恰好 %d 次",
sessionRateLimit*4, passed, sessionRateLimit)
}
}
// 建会话失败时要还名额:那次新建实际上没有发生
func TestSessionRateRelease(t *testing.T) {
l := newLimiter()
for i := 0; i < sessionRateLimit; i++ {
l.Allow("bot")
}
if ok, _ := l.Allow("bot"); ok {
t.Fatal("应已刷满")
}
l.Release("bot")
if ok, _ := l.Allow("bot"); !ok {
t.Fatal("归还名额后应能再开一条")
}
// 空记录上 Release 不该 panic
l2 := newLimiter()
l2.Release("nobody")
}
// 窗口滑过后自动恢复 —— 这正是它优于「终身额度」的地方:
// 终身额度跑满要人工重置,速率限制过一个窗口自己好
func TestSessionRateWindowSlides(t *testing.T) {
l := newLimiter()
old := time.Now().Add(-sessionRateWindow - time.Minute)
marks := make([]time.Time, sessionRateLimit)
for i := range marks {
marks[i] = old
}
l.marks["bot"] = marks
if ok, _ := l.Allow("bot"); !ok {
t.Fatal("窗口外的记录应被清掉,此次应放行")
}
if len(l.marks["bot"]) != 1 {
t.Fatalf("过期记录未清理,剩余 %d 条", len(l.marks["bot"]))
}
}
// 人类不走限速(空 agentName
func TestAllowNewSessionSkipsHumans(t *testing.T) {
for i := 0; i < sessionRateLimit*3; i++ {
if ok, _ := AllowNewSession(""); !ok {
t.Fatal("人类不该被限速")
}
}
}

View File

@ -374,7 +374,7 @@ func ListActiveUsernames(ctx context.Context) ([]string, error) {
out = append(out, s)
}
}
return out, nil
return out, rows.Err()
}
// ---------- 会话归属 ----------
@ -500,7 +500,7 @@ func AllWorkspaceNames(ctx context.Context) ([]string, error) {
out = append(out, s)
}
}
return out, nil
return out, rows.Err()
}
// IsHumanUser 判断某个三维地址 name 位是否为人类用户

View File

@ -15,10 +15,18 @@ var (
once sync.Once
)
// GetIndex 返回入口页。
//
// index.html 缺失时回退到 placeholder.html前端产物不进版本库
// 新克隆里只有占位页。回退而不是报错是故意的 ——
// 只改后端的人应当能直接 go run 起来调 API而不必先装 node。
func GetIndex() []byte {
once.Do(func() {
data, _ := fs.ReadFile(staticFS, "static/index.html")
indexHTML = data
if data, err := fs.ReadFile(staticFS, "static/index.html"); err == nil {
indexHTML = data
return
}
indexHTML, _ = fs.ReadFile(staticFS, "static/placeholder.html")
})
return indexHTML
}

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgentMail</title>
<script type="module" crossorigin src="/assets/index-C3ZRvtHr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi5AQIOl.css">
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,69 @@
<!doctype html>
<!--
前端未构建时的占位页。
为什么文件名不是 index.html那正是 Vite 构建产物的名字。用 index.html 当占位,
每次构建后真实产物都会盖掉它并被 git 视为改动;提交进去的 index.html 引用着
被忽略的 assets/,新克隆打开就是白屏而不是这张提示页。
改叫 placeholder.html构建不会产生这个名字因此永不被覆盖。
static.go 在 index.html 缺失时回退到它。
它同时让 go:embed 有文件可嵌 —— 否则新克隆连 go build 都过不去。
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AgentMail — 前端未构建</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
color: #334155;
font: 14px/1.7 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 34rem; padding: 2rem; }
h1 { font-size: 1rem; margin: 0 0 0.75rem; }
code {
background: #e2e8f0;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
pre {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.75rem 1rem;
overflow-x: auto;
font-size: 0.85em;
}
p { margin: 0 0 0.75rem; }
.dim { color: #64748b; font-size: 0.9em; }
</style>
</head>
<body>
<main>
<h1>AgentMail 前端未构建</h1>
<p>
当前二进制里没有前端产物。后端 API 仍然可用(<code>/api/v1</code>
<code>/health</code>)。
</p>
<p>构建并嵌入前端:</p>
<pre>sudo ./deploy/install.sh</pre>
<p>或者手工:</p>
<pre>cd web &amp;&amp; npm ci &amp;&amp; npm run build
rm -rf ../gateway/internal/static/static/assets
cp -r dist/. ../gateway/internal/static/static/
cd ../gateway &amp;&amp; go build ./cmd/server</pre>
<p class="dim">
构建产物不进版本库;这个占位文件的存在只是为了让
<code>go:embed</code> 在新克隆里能编译通过。
</p>
</main>
</body>
</html>

View File

@ -3,6 +3,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "no
import { randomBytes } from "node:crypto";
import { homedir } from "node:os";
import { join, dirname, basename } from "node:path";
// 自动转发去重的纯逻辑放在 lib/ 里opencode 会把入口模块的每一个导出
// 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。
import {
explicitSends,
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
@ -136,7 +143,9 @@ const sendMailTool = {
),
propose_reason: z.string().optional().describe("改名理由,一句话,展示给用户看"),
},
async execute(args) {
// context 带 sessionID用它记下「模型这一轮亲手发过信」
// 让 session.idle 的自动转发让位,避免同一件事发两封。
async execute(args, context) {
// 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 选注释而不是自造标记react-markdown 不解析 raw HTML
// 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。
@ -156,23 +165,28 @@ const sendMailTool = {
session_alias: args.session_alias || "",
attachment_ids: args.attachment_ids || [],
});
// 发成功后才记:失败的调用不该压掉自动转发 ——
// 那种情况下模型的结论还没送出去,自动转发正是兜底
noteExplicitSend(context?.sessionID, args.to, args.reply_to);
const alias = result.session_alias
? `,会话别名 ${result.session_alias}(续谈可用 ${args.to.split(".")[0]}.${result.session_alias}`
: "";
// 配额剩余必须回给模型:不然它只能撞到 403 才知道额度用完
// 那时已经没有配额发最终总结了。
const quota =
typeof result.quota_remaining === "number"
? `\n发信配额剩余 ${result.quota_remaining}/${result.quota_max}` +
(result.quota_remaining <= 1
? "配额即将用尽,请尽快向人类发送最终总结。"
// 本任务的剩余往返必须回给模型:不然它只能撞到 403 才知道额度用完
// 注意这是【这条线索】的预算,不是 Agent 的终身额度 ——
// 换一个任务就是另一份预算。
const budget =
typeof result.budget_remaining === "number"
? `\n本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` +
(result.budget_remaining <= 1
? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。"
: "")
: "";
// 回传规范化后的别名Agent 提的名字可能含非法字符被改写过
const proposed = result.rename_proposed
? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。`
: "";
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${quota}${proposed}`;
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${budget}${proposed}`;
},
};
@ -211,7 +225,7 @@ const readInboxTool = {
const limit = args.limit || 5;
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`);
if (!data.mails || data.mails.length === 0) return "收件箱为空。";
return data.mails.map((m) => {
const listed = data.mails.map((m) => {
const lines = [
`[${m.status}] ${m.from_name}: ${m.subject}`,
`邮件 ID: ${m.mail_id}`,
@ -230,6 +244,24 @@ const readInboxTool = {
lines.push(`内容: ${(m.body_preview || m.body || "").substring(0, 200)}`);
return lines.join("\n");
}).join("\n\n");
// 读过就标掉。不标的话下次拉收件箱还是这一批,
// 处理过的信和新来的信混在一起,模型分不清哪封该回。
//
// 只标本次真正列出来的而不是全部未读limit 之外的还没看过,
// 一并标掉等于让它们凭空消失。
if (args.filter !== "all") {
const ids = data.mails.map(m => m.mail_id).filter(Boolean);
if (ids.length) {
// 标记失败不该让 read_inbox 失败 —— 正文已经取到了,
// 代价只是下次会重复看到,比丢掉这次读取轻。
apiPost("/mail/read", { mail_ids: ids }).catch(e =>
console.error("[mail-bridge] 标记已读失败:", e?.message || e)
);
}
}
return listed;
},
};
@ -510,6 +542,21 @@ async function relaySummary(client, directory, sessionID) {
const ctx = mailContexts.get(mailSessionID);
if (!ctx?.replyTo) return null;
// 模型这一轮已经亲手回过这条线索 → 不再自动转发。
//
// 否则收件箱里会出现两封说同一件事的邮件生产实测311 字节与 342 字节各一封,
// 其中带附件的那封才是模型真正想发的)。判定看两点:
// - 收件人同名:它已经跟这个人说过了
// - reply_to 相同:它已经回过这封信了
// relay_key 的幂等管不了这个 —— 那个键保证「同一条消息不转两次」,
// 而这里是「模型已经自己发过了」。
if (shouldSkipAutoRelay(explicitSends.get(sessionID), ctx.replyTo, ctx.mailID)) {
explicitSends.delete(sessionID);
relayedSummaries.set(sessionID, last.id); // 记下这条已「处理」,别下次 idle 又转
console.error(`[mail-bridge] 本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`);
return null;
}
const res = await apiPost("/mail/send", {
to: ctx.replyTo,
subject: ctx.subject ? `Re: ${stripRe(ctx.subject)}` : "本轮工作总结",
@ -520,6 +567,7 @@ async function relaySummary(client, directory, sessionID) {
relay_key: last.id,
});
relayedSummaries.set(sessionID, last.id);
explicitSends.delete(sessionID); // 一轮结束,窗口关闭
return res;
}
@ -580,6 +628,10 @@ async function replyPermission(client, directory, data) {
async function deliverMail(client, directory, data, kind) {
const { sessionID, reused } = await resolveSessionForMail(client, directory, data, kind);
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。
// 不清的话,上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
explicitSends.delete(sessionID);
// 记住这轮该回给谁idle 时 relaySummary 靠它决定收件人与 reply_to。
// 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。
if (kind === "mail" && data.session_id) {
@ -652,24 +704,11 @@ export default async function mailBridge(input) {
// 所以用一个闭包把 relaySummary 需要的两个参数固定下来。
relaySummaryRef = (sid) => relaySummary(client, directory, sid);
// 心跳。响应带回配额,配额将要用尽时留一条日志。
// 注意插件代劳的转发(权限询问、最终总结)不占配额,
// 所以这条警告只关系到模型主动调 send_mail 的次数
let lastQuotaWarn = -1;
// 心跳。只保活与取待处理邮件数 ——
// 额度属于具体任务(会话),不属于 Agent所以这里没有「剩余额度」可报。
// 剩余往返随每次发信响应的 budget_remaining 回传,在那里才有意义
const heartbeat = setInterval(() => {
apiPost("/agent/heartbeat", {})
.then(res => {
const q = res?.quota;
if (!q || q.unlimited) return;
if (q.remaining <= 2 && q.remaining !== lastQuotaWarn) {
lastQuotaWarn = q.remaining;
console.error(
`[mail-bridge] 主动发信配额剩余 ${q.remaining}/${q.max_rounds}` +
`(自动转发的总结与权限询问不占配额)。`
);
}
})
.catch(() => {});
apiPost("/agent/heartbeat", {}).catch(() => {});
}, 30000);
startSSE((type, data) => {

View File

@ -0,0 +1,58 @@
// 自动转发去重的纯逻辑。
//
// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的
// 每一个导出都当成插件工厂**`Object.values(mod)` 逐个检查是不是函数),
// 多导出一个 Map 就会让整个插件加载失败:
// ERROR message="failed to load plugin" error="Plugin export is not a function"
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
/** 取三维地址的名字段admin@root.alias -> admin */
export function addrName(addr) {
return String(addr || "").split("@")[0].trim();
}
/**
* 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。
*
* session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了,
* 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。
* 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封,
* 说的是同一件事,其中带附件的那封才是模型真正想发的。
*
* 为什么不靠 relay_key 幂等:那个键是 assistant message id
* 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。
*
* 窗口是「一轮」deliverMail 投递新邮件时清空(新一轮开始),
* relaySummary 用完即清。
*/
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
export function noteExplicitSend(sessionID, to, replyTo) {
if (!sessionID) return;
let rec = explicitSends.get(sessionID);
if (!rec) {
rec = { names: new Set(), replyTos: new Set() };
explicitSends.set(sessionID, rec);
}
const name = addrName(to);
if (name) rec.names.add(name);
if (replyTo) rec.replyTos.add(String(replyTo));
}
/**
* 本轮是否该跳过自动转发。
*
* @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空
* @param replyTo 自动转发本来要发给谁(三维地址或纯名字)
* @param mailID 自动转发本来要 reply_to 的邮件 id
*/
export function shouldSkipAutoRelay(sent, replyTo, mailID) {
if (!sent) return false;
// 收件人同名:模型已经跟这个人说过了
if (sent.names.has(addrName(replyTo))) return true;
// 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过
if (mailID && sent.replyTos.has(String(mailID))) return true;
return false;
}

View File

@ -9,5 +9,8 @@
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.15.0"
},
"scripts": {
"test": "node test/auto-relay.test.mjs"
}
}

View File

@ -0,0 +1,108 @@
// 自动转发去重的回归测试。
//
// 这个 bug 是生产里真实发生的模型带附件主动回了一封311 字节),
// session.idle 又把它最后那段话自动转了一封342 字节),
// 收件箱里两封说同一件事,其中只有一封能下载附件。
//
// 逻辑放在 lib/relay-dedup.js 而不是 index.jsopencode 把入口模块的每一个
// 导出都当成插件工厂,入口多导出一个 Map 就会让整个插件加载失败
// (实测 "Plugin export is not a function",插件静默不加载,邮件全投不进去)。
import {
shouldSkipAutoRelay,
noteExplicitSend,
explicitSends,
addrName,
} from '../lib/relay-dedup.js';
let failed = 0;
const check = (name, cond, detail = '') => {
if (cond) console.log(` 通过 ${name}`);
else {
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
failed++;
}
};
console.log('自动转发去重:');
// ---- addrName ----
check('三维地址取名字段', addrName('admin@root.tidy-tiger') === 'admin');
check('纯名字原样返回', addrName('admin') === 'admin');
check('空值不炸', addrName(undefined) === '' && addrName(null) === '');
check('去空白', addrName(' admin @root') === 'admin');
// ---- 没有主动发信记录 → 照常自动转发 ----
check(
'本轮没主动发信 → 不跳过(自动转发是默认行为)',
shouldSkipAutoRelay(undefined, 'admin', 'mail-1') === false
);
// ---- 收件人同名 → 跳过 ----
explicitSends.clear();
noteExplicitSend('ses-1', 'admin@root.tidy-tiger', 'mail-1');
check(
'模型已回给同一个人 → 跳过',
shouldSkipAutoRelay(explicitSends.get('ses-1'), 'admin', 'mail-1') === true
);
check(
'收件人写法不同但同名 → 仍跳过(地址带路径/会话段)',
shouldSkipAutoRelay(explicitSends.get('ses-1'), 'admin@root', 'mail-1') === true
);
// ---- 发给别人 → 不跳过 ----
explicitSends.clear();
noteExplicitSend('ses-2', 'ops@root.new', 'mail-9');
check(
'模型主动联系了第三方,但本轮的来信还没回 → 不跳过',
shouldSkipAutoRelay(explicitSends.get('ses-2'), 'admin', 'mail-1') === false
);
// ---- reply_to 相同 → 跳过(即使收件人名字对不上)----
explicitSends.clear();
noteExplicitSend('ses-3', 'someone-else@root', 'mail-1');
check(
'已回过同一封信 → 跳过reply_to 命中,收件人名字不同也算)',
shouldSkipAutoRelay(explicitSends.get('ses-3'), 'admin', 'mail-1') === true
);
// ---- 一轮里发了多封 ----
explicitSends.clear();
noteExplicitSend('ses-4', 'ops@root', 'mail-8');
noteExplicitSend('ses-4', 'admin@root', 'mail-1');
const rec4 = explicitSends.get('ses-4');
check('一轮多封都记下', rec4.names.size === 2 && rec4.replyTos.size === 2);
check('其中任一命中即跳过', shouldSkipAutoRelay(rec4, 'admin', 'mail-1') === true);
check('都不命中则不跳过', shouldSkipAutoRelay(rec4, 'someone', 'mail-99') === false);
// ---- 无 sessionID 不记录(防止污染一个 undefined 键)----
explicitSends.clear();
noteExplicitSend(undefined, 'admin@root', 'mail-1');
check('没有 sessionID 时不记录', explicitSends.size === 0);
// ---- 空 mailID 不该让 replyTos 命中 ----
explicitSends.clear();
noteExplicitSend('ses-5', 'ops@root', '');
const rec5 = explicitSends.get('ses-5');
check('空 reply_to 不入集合', rec5.replyTos.size === 0);
check(
'本来要转的邮件没有 id 时只看收件人',
shouldSkipAutoRelay(rec5, 'admin', '') === false
);
// ---- 不变量:插件入口只能有 default 导出 ----
//
// opencode 用 Object.values(mod) 逐个检查每个导出是不是函数,
// 多导出一个 Map/常量就抛 "Plugin export is not a function"
// 整个插件静默不加载 —— 邮件全都投不进去。实测踩过这个坑。
import { readFileSync } from 'node:fs';
const entry = readFileSync(new URL('../index.js', import.meta.url), 'utf8');
const exports_ = entry.match(/^export\s+(?!default\b).*/gm) || [];
check(
'入口 index.js 只有 default 导出',
exports_.length === 0,
exports_.length ? `多出:${exports_.map(l => l.slice(0, 50)).join(' | ')}` : ''
);
check('入口确实有 default 导出', /^export default /m.test(entry));
console.log(failed === 0 ? '\n自动转发去重全部通过' : `\n自动转发去重:${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);

View File

@ -8,7 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs"
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs"
},
"dependencies": {
"react": "^18.3.1",

View File

@ -14,6 +14,9 @@ import LoginPage from './components/LoginPage';
import SetupPage from './components/SetupPage';
import AccountPage from './components/AccountPage';
import AdminUsersPage from './components/AdminUsersPage';
import NarrowNav from './components/NarrowNav';
import NarrowStack from './components/NarrowStack';
import { useIsNarrow } from './hooks/useIsNarrow';
export default function App() {
const phase = useAuthStore(s => s.phase);
@ -23,6 +26,10 @@ export default function App() {
const viewMode = useUIStore(s => s.viewMode);
const composing = useUIStore(s => s.composing);
const resetUI = useUIStore(s => s.reset);
const narrowPane = useUIStore(s => s.narrowPane);
const navOpen = useUIStore(s => s.navOpen);
const closeNav = useUIStore(s => s.closeNav);
const narrow = useIsNarrow();
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
@ -105,25 +112,84 @@ export default function App() {
return <AnonymousRoute onBootDone={bootstrap} />;
}
// 已登录主界面
// 主区域(右栏):写信 / 账号 / 管理 / 邮件详情
const main = composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : (
<MailView />
);
// 列表(中栏):仅收发件箱与联系人视图有
const list =
viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null;
// 账号/管理页没有列表栏,窄屏下要直接显示主区域,
// 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list'
const hasList = list !== null;
// ---- 窄屏:详情页从右侧滑入盖住列表,不分栏 ----
if (narrow) {
// 没有列表栏的视图(账号/管理/写信)直接铺满,不需要覆盖层:
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
if (!hasList) {
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<div className="flex-1 min-h-0 flex">{main}</div>
</NarrowShell>
);
}
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
</NarrowShell>
);
}
// ---- 宽屏:三栏并排 ----
return (
<div className="h-full flex bg-gray-50">
<Sidebar />
{viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null}
{composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
<MailView />
) : (
<MailView />
{list}
{main}
</div>
);
}
/**
* 窄屏外壳:内容区 + 底部导航 + 抽屉式侧栏。
*
* 侧栏在窄屏下是抽屉而不是常驻60px 竖条在手机上白占一成宽度,
* 而底部导航已经覆盖了日常切换,抽屉只留给不常用的入口。
*/
function NarrowShell({
children,
navOpen,
onCloseNav
}: {
children: React.ReactNode;
navOpen: boolean;
onCloseNav: () => void;
}) {
return (
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
{children}
<NarrowNav />
{navOpen && (
<>
{/* 遮罩:点空白处收起,这是移动端的通用预期 */}
<div className="fixed inset-0 bg-black/40 z-40" onClick={onCloseNav} aria-hidden="true" />
<div className="fixed left-0 top-0 bottom-0 z-50">
<Sidebar />
</div>
</>
)}
</div>
);

View File

@ -384,25 +384,33 @@ export async function forwardMail(id: string, payload: ForwardPayload) {
// ---------- 配额 ----------
export interface Quota {
/**
* Agent 的新任务默认预算与累计统计。
*
* 没有「剩余额度」字段 —— 额度属于具体任务(会话),见 SessionBudget。
* 这里只有「派给它的新任务默认几个来回」与「一共发了多少信」。
*/
export interface AgentStats {
agent_name: string;
max_rounds: number;
used_rounds: number;
/** 不限额时为 -1 */
remaining: number;
unlimited: boolean;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds: number;
/** 累计发信数,纯统计,不拦请求 */
sent_total: number;
/** 参与的未归档会话数,配合默认值判断设多少合适 */
active_sessions: number;
}
export async function adminListQuotas() {
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
export async function adminListAgentStats() {
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
}
/** 设上限0 = 不限)或把已用次数归零 */
export async function adminSetQuota(
agentName: string,
payload: { max_rounds?: number; reset?: boolean }
) {
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
/** 改该 Agent 的新任务默认预算0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>(
'PUT',
`/admin/quotas/${encodeURIComponent(agentName)}`,
{ default_rounds: defaultRounds }
);
}
// ---------- Sessions ----------

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import { LockIcon } from './icons';
import KeyPanel from './KeyPanel';
@ -85,11 +86,12 @@ export default function AccountPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-900"></h2>
</div>
<div className="max-w-lg px-6 py-6 space-y-6">
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
{/* 基本信息 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons';
import KeyPanel from './KeyPanel';
@ -95,7 +96,8 @@ export default function AdminUsersPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-1">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
<NavToggle />
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
@ -107,7 +109,7 @@ export default function AdminUsersPage() {
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
</TabButton>
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
@ -121,11 +123,11 @@ export default function AdminUsersPage() {
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
{tab === 'quotas' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<QuotaPanel />
</div>
) : tab === 'keys' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<KeyPanel
variant="agent"
keys={keys}
@ -142,7 +144,7 @@ export default function AdminUsersPage() {
<>
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-2">
{users.map(u => (
<UserCard key={u.user_id} user={u} scopes={scopes}
expanded={editing === u.user_id}
@ -180,7 +182,7 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
}) {
return (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-3">
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
@ -247,7 +249,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
return (
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
@ -304,7 +306,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</>
)}
<div className="flex items-center gap-3">
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
@ -353,7 +355,7 @@ function CreateUserForm({ scopes, onDone, onError }: {
return (
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Field label="用户名" hint="小写字母数字 . _ -">
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />

View File

@ -0,0 +1,30 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import { ChevronLeftIcon } from './icons';
/**
* 窄屏返回按钮。
*
* 只在窄屏出现:宽屏是列表与详情并排,没有「返回」这个概念 ——
* 放一个按钮在那里,点了什么也不会发生。
*
* 覆盖式布局下返回 = 让覆盖层滑出去narrowPane 回到 list
* 而不是卸载详情组件:底层列表一直挂载着,滚动位置与选中态都还在。
*/
export default function BackButton({ label = '返回' }: { label?: string }) {
const narrow = useIsNarrow();
const showList = useUIStore(s => s.showList);
if (!narrow) return null;
return (
<button
onClick={showList}
className="shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
aria-label={label}
>
<ChevronLeftIcon className="w-4 h-4" />
<span className="text-xs">{label}</span>
</button>
);
}

View File

@ -3,12 +3,13 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as api from '../api/client';
import { useUIStore } from '../stores/uiStore';
import NarrowOnly from './NarrowOnly';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import AddressInput from './AddressInput';
import { AttachmentPicker, type PendingAttachment } from './Attachments';
import { ComposeIcon } from './icons';
import { ComposeIcon, ChevronLeftIcon } from './icons';
/** 完整的写邮件页面,占据右侧整个区域 */
export default function ComposePage() {
@ -28,6 +29,8 @@ export default function ComposePage() {
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
const [maxRounds, setMaxRounds] = useState('');
// 收件 Agent 的默认预算null = 还没查到(未注册的收件人也是 null
const [agentDefault, setAgentDefault] = useState<number | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [preview, setPreview] = useState(false);
const [sending, setSending] = useState(false);
@ -39,6 +42,34 @@ export default function ComposePage() {
setCc(prefill?.cc ?? '');
}, [prefill]);
// 三维地址的 name 位 = 收件 Agent 名
const toName = to.trim().split('@')[0].trim();
// 收件人变了就重查该 Agent 的默认预算。
// 只在新建会话时需要(续谈沿用会话已有预算),所以别的情况不打接口。
const isNewTarget = /\.new\s*$/.test(to.trim());
useEffect(() => {
if (!isNewTarget || toName === '') {
setAgentDefault(null);
return;
}
let alive = true;
api
.listAgents()
.then(r => {
if (!alive) return;
const hit = r.agents?.find(a => a.agent_name === toName);
setAgentDefault(hit?.default_rounds ?? null);
})
.catch(() => {
// 查不到就不显示提示,不该因此打断写信
if (alive) setAgentDefault(null);
});
return () => {
alive = false;
};
}, [toName, isNewTarget]);
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
// 命中已有会话或走默认会话时后端会忽略该字段。
const isNewSession = /\.new\s*$/.test(to.trim());
@ -49,6 +80,10 @@ export default function ComposePage() {
? '"new" 是寻址保留字'
: null;
// 输入框的 placeholder人在派活时该看得到「不填会是多少」
const defaultRoundsHint =
agentDefault === null ? '默认' : agentDefault === 0 ? '不限' : `默认 ${agentDefault}`;
const roundsError =
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
? '预算必须是非负整数0 = 不限)'
@ -97,8 +132,20 @@ export default function ComposePage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<ComposeIcon className="w-4 h-4 text-blue-600" />
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
用 cancelCompose 而不是 showList写信态本身要一起结束
只滑走覆盖层的话下次进列表又会弹回来 */}
<NarrowOnly>
<button
onClick={cancelCompose}
className="-ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
aria-label="返回"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
</NarrowOnly>
<ComposeIcon className="w-4 h-4 text-blue-600 shrink-0" />
<h2 className="text-sm font-semibold text-gray-900"></h2>
<div className="flex-1" />
<button
@ -120,7 +167,7 @@ export default function ComposePage() {
</button>
</div>
<div className="px-6 py-4 space-y-3 border-b border-gray-200">
<div className="px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
<Field label="收件人" hint="name@path.session省略=默认会话new=新建,别名=已有会话">
<AddressInput
value={to}
@ -143,16 +190,13 @@ export default function ComposePage() {
)}
{isNewSession && (
<Field
label="往返预算"
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
>
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
<div className="flex items-center gap-2">
<input
value={maxRounds}
onChange={e => setMaxRounds(e.target.value)}
inputMode="numeric"
placeholder="不限"
placeholder={defaultRoundsHint}
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<span className="text-[11px] text-gray-400">
@ -177,7 +221,7 @@ export default function ComposePage() {
</Field>
</div>
<div className="flex-1 min-h-0 px-6 py-3 flex flex-col">
<div className="flex-1 min-h-0 px-4 md:px-6 py-3 flex flex-col">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span>
<div className="flex-1" />
@ -207,11 +251,11 @@ export default function ComposePage() {
)}
</div>
<div className="px-6 pb-3">
<div className="px-4 md:px-6 pb-3">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
</div>
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
<div className="px-4 md:px-6 py-3 border-t border-gray-200 flex items-center gap-3">
{error && <span className="text-xs text-red-600">{error}</span>}
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
<div className="flex-1" />

View File

@ -4,7 +4,17 @@ import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import type { Contact } from '../types';
import { ArchiveIcon, ComposeIcon, CheckIcon, CloseIcon, ChevronRightIcon } from './icons';
import {
ArchiveIcon,
ComposeIcon,
CheckIcon,
CloseIcon,
ChevronRightIcon,
ListViewIcon,
CardViewIcon
} from './icons';
import NavToggle from './NavToggle';
import { WorkCard } from './WorkCard';
/**
* 左侧联系人面板:列出所有 name@path.session支持
@ -24,12 +34,15 @@ export default function ContactPanel() {
const requestArchive = useContactStore(s => s.requestArchive);
const cancelArchive = useContactStore(s => s.cancelArchive);
const archive = useContactStore(s => s.archive);
const view = useContactStore(s => s.view);
const setView = useContactStore(s => s.setView);
const selectSession = useSessionStore(s => s.selectSession);
const currentSession = useSessionStore(s => s.currentSession);
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
const startCompose = useUIStore(s => s.startCompose);
const cancelCompose = useUIStore(s => s.cancelCompose);
const showDetail = useUIStore(s => s.showDetail);
useEffect(() => {
fetchContacts();
@ -39,14 +52,35 @@ export default function ContactPanel() {
cancelCompose();
clearCurrentMail();
selectSession(c.session_id);
showDetail(); // 窄屏下切到会话内容栏
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<div
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
// 卡片要放两行摘要 + 预算条320px 会挤;列表视图保持紧凑
view === 'card' ? 'md:w-[400px]' : 'md:w-[320px]'
}`}
>
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">
{view === 'card' ? '工作列表' : '联系人'}
</h2>
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
<div className="flex-1" />
{/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */}
<button
onClick={() => setView(view === 'list' ? 'card' : 'list')}
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
>
{view === 'list' ? (
<CardViewIcon className="w-3.5 h-3.5" />
) : (
<ListViewIcon className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={toggleArchivedView}
className={`text-[11px] px-1.5 py-0.5 rounded ${
@ -64,23 +98,42 @@ export default function ContactPanel() {
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
{contacts.map(c => (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
confirming={pendingArchive === c.address}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
onCancelArchive={cancelArchive}
onConfirmArchive={() => archive(c)}
/>
))}
{contacts.map(c =>
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
pendingArchive === c.address ? (
<ArchiveConfirm
key={c.session_id}
contact={c}
onCancel={cancelArchive}
onConfirm={() => archive(c)}
/>
) : view === 'card' ? (
<WorkCard
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onArchive={() => requestArchive(c.address)}
/>
) : (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
/>
)
)}
{!loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6">
{view === 'card'
? '暂无进行中的工作,发一封邮件即可开始'
: '暂无联系人,发一封邮件即可建立'}
</p>
)}
@ -110,24 +163,61 @@ export default function ContactPanel() {
);
}
/**
* 归档确认框。
*
* 列表视图与卡片视图共用:归档是破坏性操作,换个视图就换套确认 UI
* 只会让人对「自己点了什么」更没底。
*/
function ArchiveConfirm({
contact,
onCancel,
onConfirm
}: {
contact: Contact;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirm}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancel}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
function ContactRow({
contact,
active,
confirming,
onOpen,
onCompose,
onRequestArchive,
onCancelArchive,
onConfirmArchive
onRequestArchive
}: {
contact: Contact;
active: boolean;
confirming: boolean;
onOpen: () => void;
onCompose: () => void;
onRequestArchive: () => void;
onCancelArchive: () => void;
onConfirmArchive: () => void;
}) {
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
@ -136,35 +226,6 @@ function ContactRow({
minute: '2-digit'
});
if (confirming) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirmArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancelArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
return (
<div
className={`group px-3 py-2.5 rounded-lg border transition-colors ${

View File

@ -109,7 +109,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
@ -237,7 +237,7 @@ export default function KeyPanel({
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>

View File

@ -4,10 +4,12 @@ import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { ShieldIcon, PaperclipIcon } from './icons';
import NavToggle from './NavToggle';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
const cancelCompose = useUIStore(s => s.cancelCompose);
const showDetail = useUIStore(s => s.showDetail);
const inbox = useMailStore(s => s.inbox);
const sent = useMailStore(s => s.sent);
@ -29,11 +31,16 @@ export default function MailList() {
clearSession();
cancelCompose();
selectMail(m);
// 窄屏下列表与详情共用一栏,选中后要切过去;
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
showDetail();
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<div className="w-full md:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
</div>

View File

@ -10,6 +10,7 @@ import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, Forwar
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
import BackButton from './BackButton';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
@ -31,8 +32,9 @@ export default function MailView() {
const last = currentSessionMails[currentSessionMails.length - 1];
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2 flex-wrap">
<BackButton label="会话" />
<span className="text-sm font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
@ -48,12 +50,18 @@ export default function MailView() {
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} />
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
))}
</div>
<ReplyBar replyTo={last} />
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={last} />
)}
</div>
);
}
@ -81,7 +89,7 @@ export default function MailView() {
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
@ -207,7 +215,7 @@ function RenameProposalBar() {
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
@ -251,6 +259,8 @@ function RenameProposalBar() {
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [ccOpen, setCcOpen] = useState(false);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -264,7 +274,11 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, { to: to.trim(), comment: comment.trim() });
await api.forwardMail(mail.mail_id, {
to: to.trim(),
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
@ -275,22 +289,36 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3 space-y-2">
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600">
{mail.subject}
</span>
<div className="flex-1" />
<span className="text-[10px] text-gray-400"></span>
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人pi@root.new" />
{ccOpen && (
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个"
/>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前)"
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
@ -330,9 +358,10 @@ function Header({
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
return (
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5">
<h2 className="text-sm font-semibold text-gray-900">{mail.subject}</h2>
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<BackButton />
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
@ -388,7 +417,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
);
}
function ThreadCard({ mail }: { mail: Mail }) {
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
const isHuman = mail.from_name === 'human';
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
@ -418,10 +447,24 @@ function ThreadCard({ mail }: { mail: Mail }) {
</span>
)}
{mail.cc_list?.length > 0 && (
<span className="text-[10px] text-gray-400"> {mail.cc_list.length}</span>
<span
className="text-[10px] text-gray-400"
title={mail.cc_list.map(c => c.raw || c.name).join(', ')}
>
{mail.cc_list.length}
</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span>
{onForward && (
<button
onClick={onForward}
title="转发这封"
className="text-gray-400 hover:text-blue-600"
>
<ForwardIcon className="w-3 h-3" />
</button>
)}
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
@ -499,8 +542,13 @@ function PermissionPanel({ mail }: { mail: Mail }) {
function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const [body, setBody] = useState('');
const [cc, setCc] = useState('');
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
const [ccOpen, setCcOpen] = useState(false);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
@ -519,25 +567,78 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const send = async () => {
if (!body.trim()) return;
setBusy(true);
setError(null);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
cc: cc.trim(),
attachment_ids: attachments.map(a => a.id)
});
setBody('');
setCc('');
setCcOpen(false);
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
console.error(err);
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
// 原先只 console.error用户点了发送什么反应都没有
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/**
* 「回复全部」:把原邮件的其他参与方填进抄送。
*
* cc_list 是结构化的 Address后端解析过三维寻址取 raw 回填 ——
* 那是用户当初写下的原文,重新拼 name@path 会丢掉会话段。
*/
const replyAll = () => {
const others = [
`${replyTo.from_name}${replyTo.from_workspace ? '@' + replyTo.from_workspace : ''}`,
`${replyTo.to_name}${replyTo.to_workspace ? '@' + replyTo.to_workspace : ''}`,
...(replyTo.cc_list ?? []).map(c => c.raw || c.name)
]
// 去掉自己与主收件人:前者收不到自己的信没意义,后者已经在 to 里
.filter(a => a && !a.startsWith('human') && !a.startsWith(peerName))
// 同一个人可能既在 to 又在 cc 里
.filter((a, i, arr) => arr.indexOf(a) === i);
setCc(others.join(', '));
setCcOpen(true);
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3">
<p className="text-[10px] text-gray-400 mb-1 font-mono"> {target}</p>
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3">
<div className="flex items-center gap-2 mb-1">
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate"> {target}</p>
<div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && (
<button
onClick={replyAll}
className="text-[10px] text-gray-500 hover:text-blue-600"
>
</button>
)}
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
{ccOpen && (
<div className="mb-2">
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root"
/>
</div>
)}
<textarea
value={body}
onChange={e => setBody(e.target.value)}
@ -547,9 +648,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex justify-end gap-2 mt-2">
<div className="flex items-center gap-2 mt-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
<button
onClick={() => setBody('')}
onClick={() => {
setBody('');
setError(null);
}}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>

View File

@ -0,0 +1,103 @@
import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import { InboxIcon, SentIcon, ContactsIcon, ComposeIcon, UsersIcon, PersonIcon } from './icons';
/**
* 窄屏底部导航。
*
* 移动端把主导航放底部而不是顶部:拇指够得到。
* 宽屏用的是左侧竖条Sidebar两者共用 uiStore 的 viewMode
* 所以从窄拖到宽不会丢失当前位置。
*
* 这里只放最常用的几项 + 一个「更多」入口(打开抽屉式 Sidebar——
* 底部塞满图标会挤成一排看不懂的小方块。
*/
const items: {
short: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', mode: 'sent', Icon: SentIcon },
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function NarrowNav() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const narrowPane = useUIStore(s => s.narrowPane);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const isAdmin = user?.role === 'admin';
const visible = items.filter(n => !n.adminOnly || isAdmin);
return (
<nav
className="shrink-0 border-t border-slate-700 bg-slate-900 flex items-stretch"
// 底部安全区iPhone 的手势条会盖住最后一排
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{visible.map(({ short, mode, Icon }) => {
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件,
// 高亮「收件」会让人以为点它能回到列表(其实是同一项)
const active = viewMode === mode && !composing && narrowPane === 'list';
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
active ? 'text-white' : 'text-slate-400 active:bg-slate-800'
}`}
>
<Icon />
<span className="text-[10px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
{active && <span className="absolute top-0 left-1/4 right-1/4 h-0.5 bg-blue-400" />}
</button>
);
})}
<button
onClick={() => startCompose()}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
composing ? 'text-blue-300' : 'text-blue-400 active:bg-slate-800'
}`}
>
<ComposeIcon />
<span className="text-[10px] leading-none"></span>
</button>
<button
onClick={() => setViewMode('account')}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
viewMode === 'account' && !composing
? 'text-white'
: 'text-slate-400 active:bg-slate-800'
}`}
>
<PersonIcon />
<span className="text-[10px] leading-none"></span>
</button>
</nav>
);
}

View File

@ -0,0 +1,11 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
/**
* 只在窄屏渲染子元素。
*
* 不用 Tailwind 的 `md:hidden`:那只是视觉隐藏,元素仍在 DOM 与 tab 序列里,
* 宽屏用户按 Tab 会聚焦到一个看不见的返回按钮上。
*/
export default function NarrowOnly({ children }: { children: React.ReactNode }) {
return useIsNarrow() ? <>{children}</> : null;
}

View File

@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
/**
* 窄屏下的「页面覆盖」容器。
*
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
* 这样做的两个实际好处:
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
* 能让旧页面往右滑出去
*
* 因此这里必须区分「逻辑上是否打开」open与「是否还在 DOM 里」mounted
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
*/
export default function NarrowStack({
base,
overlay,
open
}: {
base: React.ReactNode;
overlay: React.ReactNode;
open: boolean;
}) {
// mounted是否在 DOM 里。entered是否已滑到位用于触发 transition
const [mounted, setMounted] = useState(open);
const [entered, setEntered] = useState(open);
const timer = useRef<number | null>(null);
useEffect(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) {
setMounted(true);
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
// translate-x-0 是同一帧内完成的transition 不会触发。
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
return () => cancelAnimationFrame(raf);
}
setEntered(false);
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
timer.current = window.setTimeout(() => {
setMounted(false);
timer.current = null;
}, 200);
return () => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
};
}, [open]);
return (
<div className="flex-1 min-h-0 relative overflow-hidden">
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
否则屏幕阅读器会读到两层内容 */}
<div className="absolute inset-0 flex" aria-hidden={open ? 'true' : undefined}>
{base}
</div>
{mounted && (
<div
className={`absolute inset-0 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full'
}`}
>
{overlay}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import { MenuIcon } from './icons';
/**
* 窄屏下打开抽屉式侧栏的按钮。
*
* 只在窄屏渲染 —— 宽屏侧栏是常驻的,放个汉堡按钮点了什么也不会发生。
* 侧栏里有底部导航没放的入口(退出登录等)。
*/
export default function NavToggle() {
const narrow = useIsNarrow();
const toggleNav = useUIStore(s => s.toggleNav);
if (!narrow) return null;
return (
<button
onClick={toggleNav}
className="-ml-1 mr-0.5 p-1 rounded text-gray-500 active:bg-gray-100"
aria-label="打开导航"
>
<MenuIcon className="w-4 h-4" />
</button>
);
}

View File

@ -3,21 +3,27 @@ import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
/**
* Agent 发信配额面板(管理员)。
* Agent 新任务默认预算(管理员)。
*
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
* **这里配的是默认值,不是额度。**
*
* 额度(往返预算)属于具体任务 —— 在写信时给、在对话页里随时改。
* 这个面板只决定「派给某个 Agent 的新任务,如果没人显式指定,默认给几个来回」:
* 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级所以分开设。
*
* 累计发信数只是观测数据,不拦任何请求 —— 之前它是「终身额度」,
* 但终身额度跑满要人工重置才能再干活,而 Agent 是长期在线的。
*/
export default function QuotaPanel() {
const [quotas, setQuotas] = useState<api.Quota[]>([]);
const [stats, setStats] = useState<api.AgentStats[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
const r = await api.adminListQuotas();
setQuotas(r.quotas);
const r = await api.adminListAgentStats();
setStats(r.quotas);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -28,11 +34,11 @@ export default function QuotaPanel() {
load();
}, [load]);
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
const apply = async (name: string, defaultRounds: number) => {
setBusy(name);
setError(null);
try {
await api.adminSetQuota(name, payload);
await api.adminSetDefaultRounds(name, defaultRounds);
await load();
setDrafts(d => {
const next = { ...d };
@ -50,76 +56,57 @@ export default function QuotaPanel() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{quotas.length}</span>
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{stats.length}</span>
</div>
<p className="text-[11px] text-gray-500">
Agent 0
Agent
Agent 0 =
<br />
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{quotas.length === 0 ? (
{stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{quotas.map(q => {
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
const dirty = draft !== String(q.max_rounds);
const exhausted = !q.unlimited && q.remaining === 0;
{stats.map(s => {
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
const dirty = draft !== String(s.default_rounds);
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{q.agent_name}
{s.agent_name}
</span>
<div className="min-w-0 flex-1">
{q.unlimited ? (
<span className="text-xs text-gray-500"> {q.used_rounds}</span>
) : (
<div className="flex items-center gap-2">
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
style={{
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
}}
/>
</div>
<span
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
>
{q.used_rounds}/{q.max_rounds}
{exhausted && ' · 已用尽'}
</span>
</div>
)}
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
<span className="text-gray-400">
{' · '} {s.active_sessions} · {s.sent_total}
</span>
</div>
<input
type="number"
min={0}
value={draft}
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
onChange={e => setDrafts(d => ({ ...d, [s.agent_name]: e.target.value }))}
inputMode="numeric"
title="新任务默认往返数0 = 不限"
className={`w-16 text-xs border rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
disabled={!dirty || busy === q.agent_name}
title="保存上限"
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
disabled={!dirty || invalid || busy === s.agent_name}
title="保存默认预算"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>
<button
onClick={() => apply(q.agent_name, { reset: true })}
disabled={busy === q.agent_name || q.used_rounds === 0}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
</div>
);
})}

View File

@ -39,7 +39,9 @@ export default function Sidebar() {
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
>
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {

View File

@ -150,7 +150,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
@ -168,7 +168,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />

View File

@ -0,0 +1,146 @@
import type { Contact } from '../types';
import {
ArchiveIcon,
ComposeIcon,
ChevronRightIcon,
GaugeIcon,
PersonIcon,
BotIcon
} from './icons';
/**
* 工作卡片:中间栏的另一种呈现。
*
* 与列表行ContactPanel 的 ContactRow的分工
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」。
* 一条线索是一件正在进行的工作,卡片上要能直接看出:
* - 主题(多由 Agent 平台的模型生成的摘要)
* - 最新一封说了什么、谁说的
* - 往返预算还剩多少 —— 预算是任务的属性,快跑满的任务需要人介入
*
* 容器(列表/滚动/空态)由 ContactPanel 负责:两种视图共用同一份数据与同一套
* 打开/写信/归档动作,只有单项的渲染不同。竖向堆叠而非网格 ——
* 卡片在中间栏里320~400px 放不下多列。
*/
export function WorkCard({
contact: c,
active,
onOpen,
onCompose,
onArchive
}: {
contact: Contact;
active: boolean;
onOpen: () => void;
onCompose: () => void;
onArchive: () => void;
}) {
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const fromHuman = c.last_from !== c.agent_name;
return (
<div
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
active ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
{c.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
{c.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate">
{c.session_alias || '(未命名会话)'}
</span>
</div>
{/* 主题是这张卡片的主角:它回答「这条线索在干什么」 */}
<p className="text-xs text-gray-800 mt-1.5 line-clamp-2 leading-snug">
{c.subject || '(无主题)'}
</p>
{c.last_preview && (
<div className="flex items-start gap-1 mt-1.5">
{fromHuman ? (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
) : (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
)}
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug">
{c.last_preview}
</p>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-gray-400">
{c.mail_count} · {time}
</span>
<div className="flex-1" />
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
</div>
</button>
<div className="flex gap-1 px-3 pb-2.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
>
<ComposeIcon className="w-3 h-3" />
</button>
<button
onClick={onArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
/**
* 预算指示条。
*
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立
* 等于纯噪声。只在真正设了上限时才占位置。
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
*/
function BudgetChip({ max, used }: { max: number; used: number }) {
if (!max || max <= 0) return null;
const remaining = Math.max(max - used, 0);
const tone =
remaining === 0
? 'bg-red-100 text-red-700'
: remaining <= 1
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-500';
return (
<span
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`}
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
>
<GaugeIcon className="w-2.5 h-2.5" />
{remaining}/{max}
</span>
);
}

View File

@ -287,3 +287,36 @@ export function GaugeIcon({ className = 'w-4 h-4' }: P) {
</Svg>
);
}
export function ChevronLeftIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m15 18-6-6 6-6" />
</Svg>
);
}
export function MenuIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M4 7h16M4 12h16M4 17h16" />
</Svg>
);
}
export function ListViewIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
</Svg>
);
}
export function CardViewIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="14" width="18" height="6" rx="1.5" />
</Svg>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
/**
* 窄屏断点。
*
* 三栏布局需要 60导航+ 320列表+ 约 400详情≈ 780px 才不挤,
* 因此以 768px 为界:以下只显示单栏。
*
* 用 matchMedia 而不是监听 resize后者每变化一像素都触发还得自己节流
* matchMedia 只在跨过阈值时回调一次。
*/
const NARROW_QUERY = '(max-width: 767px)';
export function useIsNarrow(): boolean {
const [narrow, setNarrow] = useState(() =>
typeof window === 'undefined' ? false : window.matchMedia(NARROW_QUERY).matches
);
useEffect(() => {
const mq = window.matchMedia(NARROW_QUERY);
const onChange = (e: MediaQueryListEvent) => setNarrow(e.matches);
mq.addEventListener('change', onChange);
// 挂载时同步一次:首次渲染到 effect 之间窗口可能已变化(例如手机旋屏)
setNarrow(mq.matches);
return () => mq.removeEventListener('change', onChange);
}, []);
return narrow;
}

View File

@ -2,6 +2,25 @@ import { create } from 'zustand';
import type { Contact } from '../types';
import * as api from '../api/client';
/** 中间栏的呈现方式:列表(紧凑,答"跟谁在聊")或卡片(答"在聊什么、进展如何" */
export type ContactView = 'list' | 'card';
const VIEW_KEY = 'agentmail.contactView';
/**
* 视图偏好存 localStorage。
*
* 这是纯展示偏好,不值得为它建一张表、加一个 API —— 而每次刷新都退回默认视图
* 会让人反复点同一个按钮。读取时容错localStorage 在隐私模式下可能抛异常。
*/
function loadView(): ContactView {
try {
return localStorage.getItem(VIEW_KEY) === 'card' ? 'card' : 'list';
} catch {
return 'list';
}
}
interface ContactState {
contacts: Contact[];
archivedContacts: Contact[];
@ -10,6 +29,9 @@ interface ContactState {
error: string | null;
/** 正在等待归档确认的地址 */
pendingArchive: string | null;
/** 中间栏呈现方式(持久化到 localStorage */
view: ContactView;
setView: (v: ContactView) => void;
fetchContacts: () => Promise<void>;
fetchArchived: () => Promise<void>;
@ -28,6 +50,16 @@ export const useContactStore = create<ContactState>((set, get) => ({
loading: false,
error: null,
pendingArchive: null,
view: loadView(),
setView: v => {
set({ view: v });
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
/* 存不下只是下次回到默认视图,不该让切换本身失败 */
}
},
fetchContacts: async () => {
set({ loading: true, error: null });

View File

@ -12,18 +12,61 @@ interface UIState {
startCompose: (prefill?: { to?: string; cc?: string }) => void;
cancelCompose: () => void;
/**
* 窄屏下当前显示哪一栏。
*
* 宽屏是「列表 + 详情」并排,窄屏放不下,只能一次显示一栏:
* 选中邮件 → 切到 detail点返回 → 回 list。
*
* 这个状态在宽屏下**也维护**(只是不影响渲染):否则从窄屏拖宽再拖回来,
* 用户会发现自己回到了列表页,刚打开的邮件不见了。
*/
narrowPane: 'list' | 'detail';
showDetail: () => void;
showList: () => void;
/** 侧边导航在窄屏下是抽屉,宽屏下常驻 */
navOpen: boolean;
toggleNav: () => void;
closeNav: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
export const useUIStore = create<UIState>(set => ({
viewMode: 'inbox',
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
// 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪
setViewMode: mode =>
set({
viewMode: mode,
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
}),
composing: false,
composePrefill: null,
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
cancelCompose: () => set({ composing: false, composePrefill: null }),
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
startCompose: prefill =>
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail', navOpen: false }),
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
narrowPane: 'list',
showDetail: () => set({ narrowPane: 'detail' }),
showList: () => set({ narrowPane: 'list' }),
navOpen: false,
toggleNav: () => set(s => ({ navOpen: !s.navOpen })),
closeNav: () => set({ navOpen: false }),
reset: () =>
set({
viewMode: 'inbox',
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
})
}));

View File

@ -21,6 +21,8 @@ export interface Agent {
workspaces: Workspace[];
platform: string;
status: string;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds?: number;
}
export interface Address {
@ -158,6 +160,14 @@ export interface Contact {
mail_count: number;
unread_count: number;
last_activity: string;
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
subject: string;
/** 本任务的往返预算0 = 不限) */
max_rounds: number;
used_rounds: number;
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
last_from: string;
last_preview: string;
}
export interface PermissionRequest {

View File

@ -0,0 +1,91 @@
// 窄屏布局的结构性回归测试。
//
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。
import { readFileSync } from 'node:fs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
let failed = 0;
const check = (name, cond, detail = '') => {
if (cond) {
console.log(` 通过 ${name}`);
} else {
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
failed++;
}
};
console.log('窄屏布局回归:');
// 1) 覆盖式而非分栏NarrowStack 必须同时挂载 base 与 overlay
const stack = read('../src/components/NarrowStack.tsx');
check(
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
);
check(
'关闭时延迟卸载,退出动画才有东西可播',
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
);
check(
'入场用双层 rAF避免与挂载合帧导致 transition 不触发',
(stack.match(/requestAnimationFrame/g) || []).length >= 2
);
check(
'尊重 prefers-reduced-motion',
stack.includes('motion-reduce:transition-none')
);
// 2) 固定宽度的中间栏在窄屏必须让位。
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
// ContactPanel 的卡片视图用 400px列表视图用 320px。
for (const f of ['MailList', 'ContactPanel']) {
const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full');
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
// 只看 >=200px 的min-w-[16px] 之类的徽标尺寸与布局无关
// (前置 (?<![-\w]) 排除 min-w- / max-w-,它们是约束不是宽度)。
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)[0]);
return px >= 200 && !src.includes('md:' + m);
});
check(
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`,
narrowFullWidth && bareFixed.length === 0,
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full'
);
}
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
const view = read('../src/components/MailView.tsx');
check('邮件详情有返回按钮', view.includes('<BackButton'));
const compose = read('../src/components/ComposePage.tsx');
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
const stripComments = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
const src = read(`../src/components/${f}.tsx`);
const code = stripComments(src);
check(
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
);
}
// 5) 底部导航要避开 iPhone 手势条
const nav = read('../src/components/NarrowNav.tsx');
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
// 6) 横向内边距在窄屏收窄px-6 在 375px 屏上白吃 48px
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
for (const f of wide) {
const src = read(`../src/components/${f}.tsx`);
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
check(`${f} 没有裸 px-6应为 px-4 md:px-6`, bare.length === 0, `发现 ${bare.length}`);
}
console.log(failed === 0 ? '\n窄屏布局全部通过' : `\n窄屏布局:${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);