From 07e6b789b29e1219b938482c7f5828f52f597f57 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 2 Sep 2026 14:16:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E9=A2=9D=E4=B8=8B=E6=B2=89?= =?UTF-8?q?=E5=88=B0=E4=BC=9A=E8=AF=9D=20+=20=E7=AA=84=E5=B1=8F=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=BC=8F=E5=B8=83=E5=B1=80=20+=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8D=A1=E7=89=87=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 配额重构:废除 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 把插件测试也纳入部署前门禁 --- .gitignore | 5 +- README.md | 42 +++- deploy/install.sh | 17 +- docs/API.md | 71 +++++- docs/MVP-SPEC.md | 7 +- docs/PLAN.md | 171 +++++++++++-- gateway/cmd/server/main.go | 3 + gateway/internal/db/db.go | 18 +- gateway/internal/db/migrate.go | 4 + gateway/internal/db/migrations/init.sql | 4 + .../internal/db/migrations/init_sqlite.sql | 41 +++- gateway/internal/handler/agents.go | 16 +- gateway/internal/handler/attachments.go | 21 +- gateway/internal/handler/forward.go | 73 +++--- gateway/internal/handler/helpers.go | 14 ++ gateway/internal/handler/mail.go | 142 ++++++++--- gateway/internal/handler/me.go | 38 ++- gateway/internal/handler/sessions.go | 15 +- gateway/internal/models/models.go | 7 +- gateway/internal/repo/attachments.go | 45 ++++ gateway/internal/repo/attachments_test.go | 142 +++++++++++ gateway/internal/repo/markread_test.go | 144 +++++++++++ gateway/internal/repo/quota.go | 197 ++++++++------- gateway/internal/repo/quota_test.go | 160 +++++------- gateway/internal/repo/repo.go | 157 ++++++++++-- gateway/internal/repo/repo_test.go | 228 ++++++++++++++++++ gateway/internal/repo/sessionrate.go | 139 +++++++++++ gateway/internal/repo/sessionrate_test.go | 117 +++++++++ gateway/internal/repo/users.go | 4 +- gateway/internal/static/static.go | 12 +- gateway/internal/static/static/index.html | 13 - .../internal/static/static/placeholder.html | 69 ++++++ plugins/opencode-mail-bridge/index.js | 93 ++++--- .../opencode-mail-bridge/lib/relay-dedup.js | 58 +++++ plugins/opencode-mail-bridge/package.json | 3 + .../test/auto-relay.test.mjs | 108 +++++++++ web/package.json | 2 +- web/src/App.tsx | 98 ++++++-- web/src/api/client.ts | 36 +-- web/src/components/AccountPage.tsx | 6 +- web/src/components/AdminUsersPage.tsx | 20 +- web/src/components/BackButton.tsx | 30 +++ web/src/components/ComposePage.tsx | 68 +++++- web/src/components/ContactPanel.tsx | 169 ++++++++----- web/src/components/KeyPanel.tsx | 4 +- web/src/components/MailList.tsx | 11 +- web/src/components/MailView.tsx | 148 ++++++++++-- web/src/components/NarrowNav.tsx | 103 ++++++++ web/src/components/NarrowOnly.tsx | 11 + web/src/components/NarrowStack.tsx | 77 ++++++ web/src/components/NavToggle.tsx | 26 ++ web/src/components/QuotaPanel.tsx | 95 ++++---- web/src/components/Sidebar.tsx | 4 +- web/src/components/ThreadView.tsx | 4 +- web/src/components/WorkCard.tsx | 146 +++++++++++ web/src/components/icons.tsx | 33 +++ web/src/hooks/useIsNarrow.ts | 29 +++ web/src/stores/contactStore.ts | 32 +++ web/src/stores/uiStore.ts | 51 +++- web/src/types/index.ts | 10 + web/test/narrow-layout.test.mjs | 91 +++++++ 61 files changed, 3107 insertions(+), 595 deletions(-) create mode 100644 gateway/internal/repo/attachments_test.go create mode 100644 gateway/internal/repo/markread_test.go create mode 100644 gateway/internal/repo/repo_test.go create mode 100644 gateway/internal/repo/sessionrate.go create mode 100644 gateway/internal/repo/sessionrate_test.go delete mode 100644 gateway/internal/static/static/index.html create mode 100644 gateway/internal/static/static/placeholder.html create mode 100644 plugins/opencode-mail-bridge/lib/relay-dedup.js create mode 100644 plugins/opencode-mail-bridge/test/auto-relay.test.mjs create mode 100644 web/src/components/BackButton.tsx create mode 100644 web/src/components/NarrowNav.tsx create mode 100644 web/src/components/NarrowOnly.tsx create mode 100644 web/src/components/NarrowStack.tsx create mode 100644 web/src/components/NavToggle.tsx create mode 100644 web/src/components/WorkCard.tsx create mode 100644 web/src/hooks/useIsNarrow.ts create mode 100644 web/test/narrow-layout.test.mjs diff --git a/.gitignore b/.gitignore index 1f91c3b..88ef16b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 617f0a7..c702e6e 100644 --- a/README.md +++ b/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 的搬运。 ## 会话别名 diff --git a/deploy/install.sh b/deploy/install.sh index ccc2aff..82c0730 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 二进制(此坑中过多次) diff --git a/docs/API.md b/docs/API.md index 8d8b4cb..3be2b81 100644 --- a/docs/API.md +++ b/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 或 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":["",""]}' + +# 不给 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) diff --git a/docs/MVP-SPEC.md b/docs/MVP-SPEC.md index db55e4a..5fefe1e 100644 --- a/docs/MVP-SPEC.md +++ b/docs/MVP-SPEC.md @@ -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 插件 diff --git a/docs/PLAN.md b/docs/PLAN.md index b7e4a6d..b8d3f24 100644 --- a/docs/PLAN.md +++ b/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 单实例,暂不需要) diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index 1072b8a..61ac6b0 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -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 diff --git a/gateway/internal/db/db.go b/gateway/internal/db/db.go index 3e389b8..ea3841c 100644 --- a/gateway/internal/db/db.go +++ b/gateway/internal/db/db.go @@ -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 }) } diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go index 7882e91..865cd9f 100644 --- a/gateway/internal/db/migrate.go +++ b/gateway/internal/db/migrate.go @@ -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 是建表后才能建的索引(依赖上面补的列)。 diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql index ac9c24a..419ef38 100644 --- a/gateway/internal/db/migrations/init.sql +++ b/gateway/internal/db/migrations/init.sql @@ -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 在正文里提议改会话别名()。 -- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。 ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT; diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql index aeee2e1..a31175d 100644 --- a/gateway/internal/db/migrations/init_sqlite.sql +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -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); diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index dc92fc7..f97cf42 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -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, }) } diff --git a/gateway/internal/handler/attachments.go b/gateway/internal/handler/attachments.go index a93a6f1..9725c24 100644 --- a/gateway/internal/handler/attachments.go +++ b/gateway/internal/handler/attachments.go @@ -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 } } diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index 1a3744f..d09cd9e 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -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}) } diff --git a/gateway/internal/handler/helpers.go b/gateway/internal/handler/helpers.go index 939136e..7f6b533 100644 --- a/gateway/internal/handler/helpers.go +++ b/gateway/internal/handler/helpers.go @@ -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 "" +} diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 4cc320e..6c5579f 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -47,7 +47,11 @@ type sendMailRequest struct { // // alias 为新建会话命名(仅新建时生效),使其之后可被 name@path. 寻址。 // 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 可以在正文里提议改会话别名()。 @@ -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), + }) +} diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go index f6a2880..e957259 100644 --- a/gateway/internal/handler/me.go +++ b/gateway/internal/handler/me.go @@ -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, }) } diff --git a/gateway/internal/handler/sessions.go b/gateway/internal/handler/sessions.go index a40cdab..1eea27b 100644 --- a/gateway/internal/handler/sessions.go +++ b/gateway/internal/handler/sessions.go @@ -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), }) diff --git a/gateway/internal/models/models.go b/gateway/internal/models/models.go index 74d4361..e33fc63 100644 --- a/gateway/internal/models/models.go +++ b/gateway/internal/models/models.go @@ -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"` diff --git a/gateway/internal/repo/attachments.go b/gateway/internal/repo/attachments.go index 8d365dd..31d4179 100644 --- a/gateway/internal/repo/attachments.go +++ b/gateway/internal/repo/attachments.go @@ -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() +} diff --git a/gateway/internal/repo/attachments_test.go b/gateway/internal/repo/attachments_test.go new file mode 100644 index 0000000..d822388 --- /dev/null +++ b/gateway/internal/repo/attachments_test.go @@ -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)) + } +} diff --git a/gateway/internal/repo/markread_test.go b/gateway/internal/repo/markread_test.go new file mode 100644 index 0000000..dfd4d0a --- /dev/null +++ b/gateway/internal/repo/markread_test.go @@ -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("别人的邮件被标掉了") + } +} diff --git a/gateway/internal/repo/quota.go b/gateway/internal/repo/quota.go index 850a918..b321852 100644 --- a/gateway/internal/repo/quota.go +++ b/gateway/internal/repo/quota.go @@ -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 } // ---------- 转发 ---------- diff --git a/gateway/internal/repo/quota_test.go b/gateway/internal/repo/quota_test.go index a611f9d..fca06b7 100644 --- a/gateway/internal/repo/quota_test.go +++ b/gateway/internal/repo/quota_test.go @@ -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]) } } diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 4d085c8..33906a9 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -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 +} diff --git a/gateway/internal/repo/repo_test.go b/gateway/internal/repo/repo_test.go new file mode 100644 index 0000000..c52454d --- /dev/null +++ b/gateway/internal/repo/repo_test.go @@ -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) + } + } +} diff --git a/gateway/internal/repo/sessionrate.go b/gateway/internal/repo/sessionrate.go new file mode 100644 index 0000000..7d36916 --- /dev/null +++ b/gateway/internal/repo/sessionrate.go @@ -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 } diff --git a/gateway/internal/repo/sessionrate_test.go b/gateway/internal/repo/sessionrate_test.go new file mode 100644 index 0000000..5167ff3 --- /dev/null +++ b/gateway/internal/repo/sessionrate_test.go @@ -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("人类不该被限速") + } + } +} diff --git a/gateway/internal/repo/users.go b/gateway/internal/repo/users.go index dfd58bb..cfa0ffb 100644 --- a/gateway/internal/repo/users.go +++ b/gateway/internal/repo/users.go @@ -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 位是否为人类用户 diff --git a/gateway/internal/static/static.go b/gateway/internal/static/static.go index 1861909..7afa845 100644 --- a/gateway/internal/static/static.go +++ b/gateway/internal/static/static.go @@ -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 } diff --git a/gateway/internal/static/static/index.html b/gateway/internal/static/static/index.html deleted file mode 100644 index 9b0440e..0000000 --- a/gateway/internal/static/static/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - AgentMail - - - - -
- - diff --git a/gateway/internal/static/static/placeholder.html b/gateway/internal/static/static/placeholder.html new file mode 100644 index 0000000..30cd9fe --- /dev/null +++ b/gateway/internal/static/static/placeholder.html @@ -0,0 +1,69 @@ + + + + + + + AgentMail — 前端未构建 + + + +
+

AgentMail 前端未构建

+

+ 当前二进制里没有前端产物。后端 API 仍然可用(/api/v1、 + /health)。 +

+

构建并嵌入前端:

+
sudo ./deploy/install.sh
+

或者手工:

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

+ 构建产物不进版本库;这个占位文件的存在只是为了让 + go:embed 在新克隆里能编译通过。 +

+
+ + diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 8986c60..be7643e 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -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) => { diff --git a/plugins/opencode-mail-bridge/lib/relay-dedup.js b/plugins/opencode-mail-bridge/lib/relay-dedup.js new file mode 100644 index 0000000..bccb65a --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/relay-dedup.js @@ -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; +} diff --git a/plugins/opencode-mail-bridge/package.json b/plugins/opencode-mail-bridge/package.json index d84a88d..340311d 100644 --- a/plugins/opencode-mail-bridge/package.json +++ b/plugins/opencode-mail-bridge/package.json @@ -9,5 +9,8 @@ }, "peerDependencies": { "@opencode-ai/plugin": ">=1.15.0" + }, + "scripts": { + "test": "node test/auto-relay.test.mjs" } } diff --git a/plugins/opencode-mail-bridge/test/auto-relay.test.mjs b/plugins/opencode-mail-bridge/test/auto-relay.test.mjs new file mode 100644 index 0000000..095bd7a --- /dev/null +++ b/plugins/opencode-mail-bridge/test/auto-relay.test.mjs @@ -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); diff --git a/web/package.json b/web/package.json index c61d9ee..5864411 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 3bb7f2a..78a8b38 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; } - // 已登录主界面 + // 主区域(右栏):写信 / 账号 / 管理 / 邮件详情 + const main = composing ? ( + + ) : viewMode === 'account' ? ( + + ) : viewMode === 'admin' && user?.role === 'admin' ? ( + + ) : ( + + ); + + // 列表(中栏):仅收发件箱与联系人视图有 + const list = + viewMode === 'contacts' ? ( + + ) : viewMode === 'inbox' || viewMode === 'sent' ? ( + + ) : null; + + // 账号/管理页没有列表栏,窄屏下要直接显示主区域, + // 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list') + const hasList = list !== null; + + // ---- 窄屏:详情页从右侧滑入盖住列表,不分栏 ---- + if (narrow) { + // 没有列表栏的视图(账号/管理/写信)直接铺满,不需要覆盖层: + // 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪 + if (!hasList) { + return ( + +
{main}
+
+ ); + } + return ( + + + + ); + } + + // ---- 宽屏:三栏并排 ---- return (
- {viewMode === 'contacts' ? ( - - ) : viewMode === 'inbox' || viewMode === 'sent' ? ( - - ) : null} - {composing ? ( - - ) : viewMode === 'account' ? ( - - ) : viewMode === 'admin' && user?.role === 'admin' ? ( - - ) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? ( - - ) : ( - + {list} + {main} +
+ ); +} + +/** + * 窄屏外壳:内容区 + 底部导航 + 抽屉式侧栏。 + * + * 侧栏在窄屏下是抽屉而不是常驻:60px 竖条在手机上白占一成宽度, + * 而底部导航已经覆盖了日常切换,抽屉只留给不常用的入口。 + */ +function NarrowShell({ + children, + navOpen, + onCloseNav +}: { + children: React.ReactNode; + navOpen: boolean; + onCloseNav: () => void; +}) { + return ( +
+ {children} + + {navOpen && ( + <> + {/* 遮罩:点空白处收起,这是移动端的通用预期 */} + ); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 55d74cb..0a8686b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 ---------- diff --git a/web/src/components/AccountPage.tsx b/web/src/components/AccountPage.tsx index a02f7ae..8f3d6a8 100644 --- a/web/src/components/AccountPage.tsx +++ b/web/src/components/AccountPage.tsx @@ -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 (
-
+
+

账号信息

-
+
{/* 基本信息 */}

基本资料

diff --git a/web/src/components/AdminUsersPage.tsx b/web/src/components/AdminUsersPage.tsx index 8ced352..7625879 100644 --- a/web/src/components/AdminUsersPage.tsx +++ b/web/src/components/AdminUsersPage.tsx @@ -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 (
-
+
+ setTab('users')}> 用户管理 @@ -107,7 +109,7 @@ export default function AdminUsersPage() { setTab('quotas')}> - 发信配额 + 默认预算
{notice && {notice}} @@ -121,11 +123,11 @@ export default function AdminUsersPage() { {error &&

{error}

} {tab === 'quotas' ? ( -
+
) : tab === 'keys' ? ( -
+
{creating && { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />} -
+
{users.map(u => ( -
+
@@ -247,7 +249,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: { return (
-
+
setDisplayName(e.target.value)} @@ -304,7 +306,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: { )} -
+
@@ -353,7 +355,7 @@ function CreateUserForm({ scopes, onDone, onError }: { return (
-
+
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" /> diff --git a/web/src/components/BackButton.tsx b/web/src/components/BackButton.tsx new file mode 100644 index 0000000..1900cf6 --- /dev/null +++ b/web/src/components/BackButton.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/ComposePage.tsx b/web/src/components/ComposePage.tsx index 8ab173d..b889b50 100644 --- a/web/src/components/ComposePage.tsx +++ b/web/src/components/ComposePage.tsx @@ -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(null); const [attachments, setAttachments] = useState([]); 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 (
-
- +
+ {/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。 + 用 cancelCompose 而不是 showList:写信态本身要一起结束, + 只滑走覆盖层的话下次进列表又会弹回来 */} + + + +

新建邮件

-
+
+
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" /> @@ -177,7 +221,7 @@ export default function ComposePage() {
-
+
正文(Markdown)
@@ -207,11 +251,11 @@ export default function ComposePage() { )}
-
+
-
+
{error && {error}} {okMsg && {okMsg}}
diff --git a/web/src/components/ContactPanel.tsx b/web/src/components/ContactPanel.tsx index 94859e0..ceda35e 100644 --- a/web/src/components/ContactPanel.tsx +++ b/web/src/components/ContactPanel.tsx @@ -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 ( -
-
-

联系人

+
+
+ +

+ {view === 'card' ? '工作列表' : '联系人'} +

{contacts.length}
+ {/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */} + + +
+
+ ); +} + 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 ( -
-

- 归档 {contact.address}? -

-

- 对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除 -

-
- - -
-
- ); - } - return (
-
+
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
+ {ccOpen && ( + + )} +