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:
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
42
README.md
42
README.md
@ -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 的搬运。
|
||||
|
||||
## 会话别名
|
||||
|
||||
@ -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 二进制(此坑中过多次)
|
||||
|
||||
71
docs/API.md
71
docs/API.md
@ -142,8 +142,8 @@ GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
|
||||
### 会话
|
||||
|
||||
```
|
||||
GET /me/sessions 我参与的会话
|
||||
GET /sessions/{id} 会话详情
|
||||
GET /me/sessions 我参与的会话(含 max_rounds/used_rounds)
|
||||
GET /sessions/{id} 会话详情 + 会话内邮件(含附件)
|
||||
GET /sessions/{id}/mails 会话内邮件(含附件)
|
||||
PUT /sessions/{id}/alias 改会话别名(冲突 409)
|
||||
|
||||
@ -174,15 +174,20 @@ curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN"
|
||||
-d '{"max_rounds":20,"reset":true}'
|
||||
```
|
||||
|
||||
- `max_rounds = 0`(或省略)= 本会话不限,仅受 Agent 全局配额约束
|
||||
- **两层都要过**:会话预算 + `agents.max_rounds` 全局配额。少了后者,
|
||||
Agent 自己用 `.new` 开一串会话每条都是全新预算,全局上限形同虚设
|
||||
- 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 ——
|
||||
那次往返实际上没有发生
|
||||
- `max_rounds = 0` = 本任务不限来回
|
||||
- **省略 `max_rounds` 时用收件 Agent 的 `default_rounds`**(管理员页可按 Agent 配,默认 20)
|
||||
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
|
||||
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
|
||||
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
|
||||
|
||||
**Agent 用 `.new` 开一串新会话绕过预算**,靠新建会话速率限制堵:
|
||||
同一 Agent 1 小时内最多新建 20 条会话,超出返回 `429`。
|
||||
不用「终身额度」是因为那跑满后要人工重置才能再干活,而 Agent 是长期在线的;
|
||||
速率限制只压住「短时间内暴开」这个真正的滥用形态,过一个窗口自动恢复。
|
||||
人类不受此限(手工点「新建邮件」的频率天然受限),
|
||||
被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀。
|
||||
省略 session 位的「默认会话」也不计入:一个 `name@path` 只有一条,不构成暴开手段。
|
||||
|
||||
### Agent 提议改会话别名
|
||||
|
||||
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
|
||||
@ -218,6 +223,16 @@ POST /contacts/archive 归档
|
||||
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
|
||||
给 `name`+`path` 返回已有会话别名与 `new`。
|
||||
|
||||
`GET /contacts` 的每条记录除了地址与计数,还带着卡片视图所需的一整套状态:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `subject` | 会话主题(多由 Agent 平台的模型生成的摘要) |
|
||||
| `max_rounds` / `used_rounds` | 本任务的往返预算(0 = 不限) |
|
||||
| `last_from` / `last_preview` | 最后一封邮件的发件人与正文摘要(服务端已按字符截断到 90) |
|
||||
|
||||
这些字段与列表一次取回,不需要逐条会话再请求一次。
|
||||
|
||||
### 权限决策
|
||||
|
||||
```
|
||||
@ -264,10 +279,21 @@ POST|GET /admin/agent-keys 签发/登记 Agent 密钥
|
||||
DELETE /admin/agent-keys/{id}
|
||||
POST /admin/agent-keys/{id}/bind
|
||||
|
||||
GET /admin/quotas Agent 发信配额
|
||||
PUT /admin/quotas/{name} 设上限 {max_rounds} 或归零 {reset:true}
|
||||
GET /admin/quotas Agent 新任务默认预算 + 累计统计
|
||||
PUT /admin/quotas/{name} 设默认预算 {default_rounds}
|
||||
```
|
||||
|
||||
`/admin/quotas` 配的是**默认值,不是额度**。额度属于具体任务(会话),见「往返预算」。
|
||||
这里只决定「派给某个 Agent 的新任务,没人显式指定时默认几个来回」——
|
||||
跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||||
|
||||
```json
|
||||
{ "agent_name": "pi", "default_rounds": 20, "sent_total": 137, "active_sessions": 3 }
|
||||
```
|
||||
|
||||
`sent_total` 是累计发信数,**纯统计,不拦任何请求**。它原本是「终身额度」,
|
||||
但那种额度跑满要管理员手工重置才能再干活,而 Agent 是长期在线的 —— 已降级为观测数据。
|
||||
|
||||
## 四、Agent 接口
|
||||
|
||||
```
|
||||
@ -275,6 +301,7 @@ POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
|
||||
POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota
|
||||
POST /mail/send 发信(扣配额)
|
||||
GET /mail/inbox 收件箱(含附件清单)
|
||||
POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉)
|
||||
POST /mail/{id}/forward 转发(扣配额)
|
||||
POST /permission/request 请求人类决策
|
||||
POST /attachments 上传附件
|
||||
@ -282,9 +309,29 @@ GET /attachments/{id} 下载附件
|
||||
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
|
||||
```
|
||||
|
||||
发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。
|
||||
发信与转发扣**本任务(会话)的往返预算**。
|
||||
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
|
||||
|
||||
### 标记已读
|
||||
|
||||
```bash
|
||||
# 标记指定几封(一次最多 200 封)
|
||||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" \
|
||||
-d '{"mail_ids":["<id1>","<id2>"]}'
|
||||
|
||||
# 不给 mail_ids(或空 body)= 把收件箱里全部未读标掉
|
||||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
|
||||
```
|
||||
|
||||
没有它 Agent 每次拉收件箱都会重复捞同一批旧邮件,处理过的和新来的混在一起。
|
||||
插件的 `read_inbox` 会自动标掉本次列出的那些(只标列出的 —— limit 之外的还没看过)。
|
||||
|
||||
- 鉴权写在 `UPDATE` 的 `WHERE` 里:不是发给自己(也没被抄送)的邮件根本改不动
|
||||
- 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
|
||||
- 重复标记已读的邮件返回 `marked: 0`,不是错误(Agent 常把上一轮的 id 原样传回)
|
||||
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到,
|
||||
标了只会让计数与用户看到的对不上
|
||||
|
||||
### 免配额通道:harness 代劳的转发
|
||||
|
||||
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
|
||||
@ -312,8 +359,8 @@ curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
|
||||
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
|
||||
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
|
||||
|
||||
`max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。
|
||||
注意 Agent 全局配额与会话往返预算是两层,都要过。
|
||||
额度只有一层 —— **本任务(会话)的往返预算**。剩余次数随发信响应的
|
||||
`budget_remaining` 回传;心跳不再回传额度(额度不属于 Agent,属于任务)。
|
||||
|
||||
## 五、实时推送(SSE)
|
||||
|
||||
|
||||
@ -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 插件
|
||||
|
||||
|
||||
171
docs/PLAN.md
171
docs/PLAN.md
@ -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 单实例,暂不需要)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 是建表后才能建的索引(依赖上面补的列)。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -3,6 +3,14 @@
|
||||
-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言:
|
||||
-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成)
|
||||
-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time)
|
||||
-- 默认值不用 CURRENT_TIMESTAMP:它只有【秒】精度,同一秒内插入的多行
|
||||
-- 按 created_at 排序结果不确定,
|
||||
-- 「会话里最早/最后那封邮件」都会取错行
|
||||
-- (实测同秒插 5 封,排出来的顺序是乱的)。
|
||||
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
|
||||
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
|
||||
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
|
||||
-- 直接决定 UI 上「最新进展」显示哪一封。
|
||||
-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索)
|
||||
-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责)
|
||||
-- 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);
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
// 没有附件的邮件保持 nil:Attachments 带 omitempty,
|
||||
// 填空切片只会给每封邮件的 JSON 加一个 "attachments":[]
|
||||
if as := byMail[m.ID]; len(as) > 0 {
|
||||
m.Attachments = as
|
||||
}
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
142
gateway/internal/repo/attachments_test.go
Normal file
142
gateway/internal/repo/attachments_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
144
gateway/internal/repo/markread_test.go
Normal file
144
gateway/internal/repo/markread_test.go
Normal 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") // 主收件人是 other,bot 被抄送
|
||||
|
||||
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("别人的邮件被标掉了")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
// ---------- 转发 ----------
|
||||
|
||||
@ -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 后 %+v,want 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 后 %+v,want 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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
228
gateway/internal/repo/repo_test.go
Normal file
228
gateway/internal/repo/repo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
gateway/internal/repo/sessionrate.go
Normal file
139
gateway/internal/repo/sessionrate.go
Normal 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 }
|
||||
117
gateway/internal/repo/sessionrate_test.go
Normal file
117
gateway/internal/repo/sessionrate_test.go
Normal 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("人类不该被限速")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 位是否为人类用户
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
69
gateway/internal/static/static/placeholder.html
Normal file
69
gateway/internal/static/static/placeholder.html
Normal 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 && npm ci && npm run build
|
||||
rm -rf ../gateway/internal/static/static/assets
|
||||
cp -r dist/. ../gateway/internal/static/static/
|
||||
cd ../gateway && go build ./cmd/server</pre>
|
||||
<p class="dim">
|
||||
构建产物不进版本库;这个占位文件的存在只是为了让
|
||||
<code>go:embed</code> 在新克隆里能编译通过。
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@ -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) => {
|
||||
|
||||
58
plugins/opencode-mail-bridge/lib/relay-dedup.js
Normal file
58
plugins/opencode-mail-bridge/lib/relay-dedup.js
Normal 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;
|
||||
}
|
||||
@ -9,5 +9,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node test/auto-relay.test.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
108
plugins/opencode-mail-bridge/test/auto-relay.test.mjs
Normal file
108
plugins/opencode-mail-bridge/test/auto-relay.test.mjs
Normal file
@ -0,0 +1,108 @@
|
||||
// 自动转发去重的回归测试。
|
||||
//
|
||||
// 这个 bug 是生产里真实发生的:模型带附件主动回了一封(311 字节),
|
||||
// session.idle 又把它最后那段话自动转了一封(342 字节),
|
||||
// 收件箱里两封说同一件事,其中只有一封能下载附件。
|
||||
//
|
||||
// 逻辑放在 lib/relay-dedup.js 而不是 index.js:opencode 把入口模块的每一个
|
||||
// 导出都当成插件工厂,入口多导出一个 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);
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
30
web/src/components/BackButton.tsx
Normal file
30
web/src/components/BackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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 ${
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
清空
|
||||
|
||||
103
web/src/components/NarrowNav.tsx
Normal file
103
web/src/components/NarrowNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
web/src/components/NarrowOnly.tsx
Normal file
11
web/src/components/NarrowOnly.tsx
Normal 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;
|
||||
}
|
||||
77
web/src/components/NarrowStack.tsx
Normal file
77
web/src/components/NarrowStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
web/src/components/NavToggle.tsx
Normal file
26
web/src/components/NavToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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" />
|
||||
|
||||
146
web/src/components/WorkCard.tsx
Normal file
146
web/src/components/WorkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
web/src/hooks/useIsNarrow.ts
Normal file
29
web/src/hooks/useIsNarrow.ts
Normal 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;
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
})
|
||||
}));
|
||||
|
||||
@ -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 {
|
||||
|
||||
91
web/test/narrow-layout.test.mjs
Normal file
91
web/test/narrow-layout.test.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user