feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
# ---- 依赖与构建产物 ----
|
||||
#
|
||||
# 前端只在**构建期**用到 npm:`npm run build` 出的 dist/ 被 cp 进
|
||||
# gateway/internal/static/static/ 再由 go:embed 编进二进制。
|
||||
# 部署机上没有 node,产物就是「一个二进制 + 一个 .db 文件」。
|
||||
# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
plugins/*/node_modules/
|
||||
|
||||
# go:embed 的输入目录 = web/dist 的副本,同属构建产物。
|
||||
# 注意 embed 要求目录存在才能编译,克隆后需先跑一次
|
||||
# `deploy/install.sh`(或 npm run build && cp -r web/dist ...)。
|
||||
gateway/internal/static/static/
|
||||
|
||||
# Go 构建产物
|
||||
gateway/agentmail-gateway
|
||||
gateway/gw
|
||||
*.test
|
||||
|
||||
# ---- 运行态数据 ----
|
||||
#
|
||||
# SQLite 库与附件目录:生产在 /opt/agentmail/data,
|
||||
# 本地调试可能落在仓库里,绝不能提交(含真实邮件与凭证哈希)。
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
data/
|
||||
attachments/
|
||||
|
||||
# ---- 凭证 ----
|
||||
#
|
||||
# 管理员密码、Agent 密钥、opencode server password 都在 env 文件里。
|
||||
.env
|
||||
*.env
|
||||
|
||||
# ---- Agent 工具的运行态目录 ----
|
||||
#
|
||||
# 这些是 pi / omo 在仓库里落的会话与后台任务状态,属于本机运行痕迹,
|
||||
# 不是项目的一部分。
|
||||
.pi/
|
||||
.omo/
|
||||
.pi-glla/
|
||||
.codegraph
|
||||
|
||||
# ---- 编辑器与系统 ----
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
220
README.md
Normal file
220
README.md
Normal file
@ -0,0 +1,220 @@
|
||||
# 邮件驱动·多智能体协作平台 (AgentMail)
|
||||
|
||||
> 以「邮件交互」为统一范式的多 Agent 调度系统
|
||||
|
||||
人给 Agent 发邮件派活,Agent 之间互相发邮件协作,需要人拍板时发一封「权限请求」邮件等回复。
|
||||
所有交互都是邮件,所以协作过程天然可读、可追溯、可归档。
|
||||
|
||||
## 核心概念:三维寻址
|
||||
|
||||
收件人地址形如 `name@path.session`:
|
||||
|
||||
| 地址 | 含义 |
|
||||
|------|------|
|
||||
| `pi@root` | 投递到 `pi` 在 `root` 工作区的**默认会话**(从未通信过则建立) |
|
||||
| `pi@root.new` | **强制新建**一个会话 |
|
||||
| `pi@root.fix-leak` | 投递到别名为 `fix-leak` 的**已有会话**;不存在则报「无法送达」,不会静默新建 |
|
||||
| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) |
|
||||
|
||||
`path` 内可以含 `/` 和 `.`,解析时按**最后一个 `.`** 切分 session 位。
|
||||
|
||||
会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台
|
||||
本就会由模型为会话生成摘要标题和 slug,AgentMail 直接复用,不另造一套。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
# 后端:默认用内置 SQLite,无需任何外部依赖
|
||||
cd gateway
|
||||
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
|
||||
# → http://localhost:8180
|
||||
|
||||
# 前端(另开一个终端)
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # → http://localhost:5173,自动代理到 8180
|
||||
```
|
||||
|
||||
### 部署
|
||||
|
||||
```bash
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。
|
||||
产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。
|
||||
首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。
|
||||
|
||||
### 构建期依赖
|
||||
|
||||
npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。
|
||||
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
|
||||
**部署机上不需要 node**。
|
||||
|
||||
`web/node_modules`、`web/dist`、`gateway/internal/static/static/` 与编译出的二进制都是
|
||||
构建产物,不进版本库(见 `.gitignore`)。因此**克隆后必须先跑一次 `deploy/install.sh`**
|
||||
——`go:embed` 要求那个目录存在才能编译,否则 `go build` 直接失败。只想编 Go 的话:
|
||||
|
||||
```bash
|
||||
cd web && npm ci && npm run build
|
||||
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
|
||||
cd ../gateway && go build ./cmd/server
|
||||
```
|
||||
|
||||
### 数据库
|
||||
|
||||
默认 SQLite,落在 `$AGENTMAIL_DATA_DIR/agentmail.db`(默认 `./data/`)。想接外部库就设
|
||||
`DATABASE_URL`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://user:pass@host:5432/agentmail # 外部 PostgreSQL
|
||||
DATABASE_URL=sqlite:///var/lib/agentmail/mail.db # 指定 SQLite 路径
|
||||
DATABASE_URL= # 留空 = 内置 SQLite
|
||||
```
|
||||
|
||||
两种方言共用一份 repo 层 SQL,差异集中在 `internal/db`(占位符、`NOW()`、JSON 包含判断、
|
||||
唯一冲突识别)。SQLite 开了 WAL,读写不互斥。
|
||||
|
||||
### 接入 Agent
|
||||
|
||||
以 opencode 为例:
|
||||
|
||||
```bash
|
||||
# 在 opencode 配置的 plugin 列表里加上本地路径
|
||||
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
|
||||
```
|
||||
|
||||
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
|
||||
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
|
||||
收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。
|
||||
|
||||
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
|
||||
|
||||
- **平台原生的权限询问**:opencode 拦下一个危险操作时(`permission.ask`),
|
||||
插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。
|
||||
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
|
||||
它可能忘了调,而真正被拦下的那次询问反而没人看见。
|
||||
- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。
|
||||
模型已经把话说完了,插件只是搬运。
|
||||
|
||||
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
|
||||
|
||||
**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志,
|
||||
管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往
|
||||
服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`。
|
||||
|
||||
密钥分两类,权限边界不同:
|
||||
|
||||
| | 签发方 | 用途 | 不能做什么 |
|
||||
|---|---|---|---|
|
||||
| Agent 密钥 | 管理员 | 注册、收发邮件、订阅 SSE | 读不了人类邮箱 |
|
||||
| 用户密钥 | 用户自助 | 第三方客户端访问自己的邮箱 | 注册不了 Agent |
|
||||
|
||||
三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。
|
||||
|
||||
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agentmail/
|
||||
├── docs/
|
||||
│ ├── PLAN.md # 分阶段实施计划
|
||||
│ └── MVP-SPEC.md # MVP 技术规格书
|
||||
├── gateway/ # 后端(Go,单二进制)
|
||||
│ ├── cmd/server/ # 入口与路由表
|
||||
│ └── internal/
|
||||
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
|
||||
│ ├── models/ # 三维地址解析、领域模型
|
||||
│ ├── repo/ # 数据访问
|
||||
│ ├── handler/ # HTTP 处理
|
||||
│ ├── middleware/ # Agent / 用户双认证
|
||||
│ ├── sse/ # 事件推送(按收件人分流)
|
||||
│ └── static/ # go:embed 的前端产物
|
||||
├── plugins/
|
||||
│ └── opencode-mail-bridge/ # opencode 桥接插件
|
||||
├── web/ # 前端(React + Vite + Tailwind)
|
||||
└── deploy/ # systemd 单元 + 安装脚本
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go + chi |
|
||||
| 数据库 | SQLite(默认,零依赖)/ PostgreSQL(可选) |
|
||||
| 前端 | React 18 + TypeScript + Vite + TailwindCSS |
|
||||
| 通信 | HTTP REST + SSE |
|
||||
| 认证 | 人类 bcrypt + Cookie;密钥认证(Agent / 用户两类,Bearer) |
|
||||
| 附件 | 内容寻址磁盘存储(sha256),元数据入库 |
|
||||
| 对话树 | parent_mail_id 递归 CTE,按方向分块加载 |
|
||||
| 部署 | 单二进制 + SQLite 文件,systemd 托管 |
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd gateway && go build ./... && go test ./... # 后端
|
||||
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
|
||||
```
|
||||
|
||||
## WebAPI
|
||||
|
||||
WebUI 调用的就是这套公开 API,没有「仅前端可用」的私有通道 —— 第三方客户端拿一把用户密钥
|
||||
即可获得与网页完全相同的能力:
|
||||
|
||||
```bash
|
||||
# 在网页「账号 → 客户端连接密钥」创建密钥,然后
|
||||
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
`src/api/` 本身就是可复用的客户端 SDK,基地址与凭证集中在 `src/api/config.ts`,
|
||||
同一份构建产物可通过 `window.__AGENTMAIL_API_BASE__` 指向不同后端。
|
||||
|
||||
完整接口见 [WebAPI 文档](docs/API.md)。
|
||||
|
||||
## 往返预算
|
||||
|
||||
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
|
||||
所以预算落在会话上,在写信时给、在对话页头部随时调:
|
||||
|
||||
- 新建邮件时填「往返预算」(留空 = 不限)
|
||||
- 对话页头部点预算徽标即可改上限,或把已用次数归零
|
||||
- Agent 全局配额(管理员页)仍然生效,两层都要过 ——
|
||||
否则 Agent 自己用 `.new` 开一串会话,每条都是全新预算,全局上限就形同虚设
|
||||
|
||||
插件自动转发的权限询问与最终总结**不占用**任何一层:配额约束的是模型的自主发信,
|
||||
不是 harness 的搬运。
|
||||
|
||||
## 会话别名
|
||||
|
||||
会话别名是寻址的第三维(`name@path.别名`)。默认**复用 Agent 平台自己的命名机制** ——
|
||||
opencode 创建会话时就有 slug,首轮对话后模型会生成摘要标题,平台叫什么本侧就叫什么,
|
||||
不另造一套。
|
||||
|
||||
Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头:
|
||||
别名是人的寻址入口,Agent 中途改掉会让人刚记住的地址立刻失效。
|
||||
|
||||
## 对话树
|
||||
|
||||
邮件的 `parent_mail_id` 天然编码了树结构(回复指向来信,转发指向原件),
|
||||
所以对话树直接用递归查询在 `mails` 上展开,不额外维护一张树表 —— 那会变成第二份真相。
|
||||
|
||||
树可以跨会话:转发把线索引到新会话,却仍属同一条线索。邮件详情页点「对话树」查看,
|
||||
首屏只加载当前屏幕附近的节点,上滑逐步补齐更早的往来。
|
||||
|
||||
## 附件
|
||||
|
||||
支持给邮件附加文件。上传与发信是两步:先 `POST /me/attachments` 拿 `attachment_id`,
|
||||
再在发信时放进 `attachment_ids`。
|
||||
|
||||
内容按 sha256 内容寻址存磁盘(数据库只存元数据),同内容重复上传不占额外空间;
|
||||
下载一律强制 `octet-stream` + `attachment`,绝不按声明的 MIME 内联渲染。
|
||||
单个默认上限 25MB,未随邮件发出的附件 24 小时后自动清理。
|
||||
|
||||
## 文档
|
||||
|
||||
- [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定
|
||||
- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准
|
||||
- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节
|
||||
36
deploy/agentmail-gateway.service
Normal file
36
deploy/agentmail-gateway.service
Normal file
@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=AgentMail Gateway - email-driven multi-agent collaboration platform
|
||||
Documentation=https://github.com/agentmail
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/agentmail
|
||||
ExecStart=/opt/agentmail/agentmail-gateway
|
||||
|
||||
# 数据库:留空即用内置 SQLite(/opt/agentmail/data/agentmail.db)。
|
||||
# 要接外部 PostgreSQL 就把 DATABASE_URL 填成 postgres://user:pass@host:5432/db。
|
||||
Environment=DATABASE_URL=
|
||||
Environment=AGENTMAIL_DATA_DIR=/opt/agentmail/data
|
||||
Environment=PORT=8180
|
||||
|
||||
# 附件:内容存磁盘(默认在 data/attachments),单个上限 25MB
|
||||
Environment=AGENTMAIL_MAX_ATTACHMENT_BYTES=26214400
|
||||
|
||||
# 首启创建的管理员。密码放在 EnvironmentFile 里,避免出现在 systemctl show 输出。
|
||||
EnvironmentFile=/etc/agentmail/gateway.env
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
# 只需要读自己的程序目录与写 data/,其余文件系统一律只读
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/agentmail/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
98
deploy/install.sh
Executable file
98
deploy/install.sh
Executable file
@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 把 AgentMail Gateway 与 opencode serve 安装为 systemd 服务。
|
||||
#
|
||||
# sudo ./deploy/install.sh
|
||||
#
|
||||
# 幂等:重复执行等价于「重新构建 + 重启」。已存在的 env 文件不会被覆盖,
|
||||
# 因为里面有管理员密码与 Agent secret,重装不该把它们冲掉。
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PREFIX=/opt/agentmail
|
||||
ETC=/etc/agentmail
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0" >&2; exit 1; }
|
||||
|
||||
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 )
|
||||
|
||||
echo "==> 前端产物嵌入 Gateway"
|
||||
rm -rf "$REPO/gateway/internal/static/static"
|
||||
cp -r "$REPO/web/dist" "$REPO/gateway/internal/static/static"
|
||||
|
||||
echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)"
|
||||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||||
rm -f "$REPO/gateway/agentmail-gateway"
|
||||
( cd "$REPO/gateway" && go vet ./... && go test ./... && go build -o "$REPO/gateway/agentmail-gateway" ./cmd/server )
|
||||
|
||||
echo "==> 安装到 $PREFIX"
|
||||
install -d "$PREFIX" "$PREFIX/data" "$ETC"
|
||||
install -m 0755 "$REPO/gateway/agentmail-gateway" "$PREFIX/agentmail-gateway"
|
||||
|
||||
# ---- env 文件:仅在缺失时生成,密码随机 ----
|
||||
if [[ ! -f "$ETC/gateway.env" ]]; then
|
||||
ADMIN_PASS="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
|
||||
cat > "$ETC/gateway.env" <<EOF
|
||||
# AgentMail Gateway 环境变量
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=$ADMIN_PASS
|
||||
|
||||
# 生产环境走 HTTPS 时置 true,Cookie 才会带 Secure 标记
|
||||
SECURE_COOKIE=false
|
||||
|
||||
# 允许的前端跨域来源(逗号分隔);单二进制自带前端时通常无需配置
|
||||
# CORS_ORIGINS=https://mail.example.com
|
||||
EOF
|
||||
chmod 0600 "$ETC/gateway.env"
|
||||
echo " 已生成 $ETC/gateway.env(管理员 admin / $ADMIN_PASS)"
|
||||
else
|
||||
echo " $ETC/gateway.env 已存在,保留不动"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ETC/opencode.env" ]]; then
|
||||
cat > "$ETC/opencode.env" <<EOF
|
||||
# opencode mail-bridge 插件配置
|
||||
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
|
||||
AGENTMAIL_AGENT_NAME=opencode
|
||||
|
||||
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
|
||||
# 拿着它到 Web 后台「Agent 密钥」登记即可接入(journalctl -u opencode-serve | grep mail-bridge)。
|
||||
# 也可以先在后台签发密钥,再把它填在这里。
|
||||
AGENTMAIL_AGENT_KEY=
|
||||
AGENTMAIL_CONFIG_DIR=/opt/agentmail/agent-config
|
||||
|
||||
# 处理来信时使用的模型
|
||||
AGENTMAIL_REPLY_PROVIDER=llmsproxy
|
||||
AGENTMAIL_REPLY_MODEL=AUTO
|
||||
|
||||
# opencode serve 绑在 127.0.0.1,但同机任何进程都能调它开会话,
|
||||
# 因此仍然设置访问密码。
|
||||
OPENCODE_SERVER_PASSWORD=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
EOF
|
||||
chmod 0600 "$ETC/opencode.env"
|
||||
install -d -m 0700 "$PREFIX/agent-config"
|
||||
echo " 已生成 $ETC/opencode.env(server password 为随机值;接入密钥首启时本地生成)"
|
||||
else
|
||||
echo " $ETC/opencode.env 已存在,保留不动"
|
||||
fi
|
||||
|
||||
echo "==> 安装 systemd 单元"
|
||||
install -m 0644 "$REPO/deploy/agentmail-gateway.service" /etc/systemd/system/
|
||||
install -m 0644 "$REPO/deploy/opencode-serve.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "==> 启用并启动"
|
||||
systemctl enable --now agentmail-gateway.service
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
systemctl enable --now opencode-serve.service
|
||||
else
|
||||
echo " 未找到 opencode,跳过 opencode-serve(装好后执行:systemctl enable --now opencode-serve)"
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
echo
|
||||
echo "==> 状态"
|
||||
systemctl --no-pager --lines=0 status agentmail-gateway.service || true
|
||||
curl -sf -m 5 http://127.0.0.1:8180/health && echo " 健康检查通过" || echo " 健康检查失败,查看:journalctl -u agentmail-gateway -n 50"
|
||||
30
deploy/opencode-serve.service
Normal file
30
deploy/opencode-serve.service
Normal file
@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=opencode headless server (AgentMail mail-bridge host)
|
||||
After=network-online.target agentmail-gateway.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/program/agentmail
|
||||
ExecStart=/usr/local/bin/opencode serve --port 4097 --hostname 127.0.0.1
|
||||
|
||||
# opencode 靠 HOME 定位 ~/.config/opencode(插件列表、provider 配置、认证)。
|
||||
# systemd 不会自动注入 HOME,不显式给就加载不到 mail-bridge 插件。
|
||||
Environment=HOME=/root
|
||||
|
||||
# mail-bridge 插件凭这组凭证向 Gateway 注册并收发邮件
|
||||
EnvironmentFile=/etc/agentmail/opencode.env
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# opencode 惰加载插件:进程启动时不载 plugin,要等第一个 /session 请求。
|
||||
# 不预热的后果是重启后紧接着到的第一封邮件会丢(mail-bridge 还没订阅 SSE)。
|
||||
# 失败不能拘到单元本身,所以结尾固定 true。
|
||||
ExecStartPost=/bin/sh -c 'for i in $(seq 1 30); do \
|
||||
curl -sf -m 3 -u "opencode:$OPENCODE_SERVER_PASSWORD" http://127.0.0.1:4097/session >/dev/null && exit 0; \
|
||||
sleep 1; \
|
||||
done; true'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
377
docs/API.md
Normal file
377
docs/API.md
Normal file
@ -0,0 +1,377 @@
|
||||
# AgentMail WebAPI
|
||||
|
||||
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
|
||||
这份文档描述如何以纯 API 方式接入。
|
||||
|
||||
基地址:`{host}/api/v1`
|
||||
|
||||
## 一、认证
|
||||
|
||||
三类调用者,各有凭证,互不越界:
|
||||
|
||||
| 调用者 | 凭证 | 可访问 |
|
||||
|--------|------|--------|
|
||||
| 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 |
|
||||
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
|
||||
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 |
|
||||
|
||||
两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent,
|
||||
Agent 密钥读不了人类邮箱。
|
||||
|
||||
### 取得用户密钥
|
||||
|
||||
在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:
|
||||
|
||||
```bash
|
||||
curl -X POST {host}/api/v1/me/keys \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b cookies.txt \
|
||||
-d '{"label":"我的客户端","key_type":"permanent"}'
|
||||
```
|
||||
|
||||
密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。
|
||||
类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。
|
||||
|
||||
### 之后每个请求
|
||||
|
||||
```bash
|
||||
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### 两处例外:`?access_token=`
|
||||
|
||||
`EventSource`(SSE)与 `<a download>` 由浏览器直接发起,无法设置请求头。
|
||||
只有这两个端点额外接受 query 令牌:
|
||||
|
||||
- `GET /events/stream?access_token=<token>`
|
||||
- `GET /me/attachments/{id}?access_token=<token>`
|
||||
|
||||
其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。
|
||||
|
||||
## 二、三维寻址
|
||||
|
||||
收件人地址形如 `name@path.session`,`session` 位三种语义:
|
||||
|
||||
| 地址 | 含义 |
|
||||
|------|------|
|
||||
| `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) |
|
||||
| `pi@root.new` | **强制新建**会话 |
|
||||
| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 |
|
||||
| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) |
|
||||
|
||||
`path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。
|
||||
会话别名负责寻址,因此全局唯一;`new` 是保留字。
|
||||
|
||||
## 三、人类接口
|
||||
|
||||
### 邮件
|
||||
|
||||
```
|
||||
POST /me/mail/send 发信
|
||||
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N)
|
||||
GET /me/mail/sent 发件箱
|
||||
GET /mail/{id} 单封详情(含附件列表)
|
||||
GET /mail/{id}/thread 对话树(分块加载,见下)
|
||||
POST /mail/{id}/read 标记已读
|
||||
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
|
||||
```
|
||||
|
||||
发信请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "pi@root.new",
|
||||
"cc": "alice@.new, bob@.new",
|
||||
"subject": "标题",
|
||||
"body": "Markdown 正文",
|
||||
"reply_to": "<mail_id>",
|
||||
"session_alias": "fix-leak",
|
||||
"attachment_ids": ["<attachment_id>"],
|
||||
"max_rounds": 3
|
||||
}
|
||||
```
|
||||
|
||||
`reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾
|
||||
(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段,
|
||||
每封新信都会悄悄改掉对方正在遵守的约定。
|
||||
|
||||
### 对话树
|
||||
|
||||
```
|
||||
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
|
||||
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
|
||||
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
|
||||
```
|
||||
|
||||
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
|
||||
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor_mail_id": "...",
|
||||
"dir": "around",
|
||||
"nodes": [
|
||||
{
|
||||
"mail_id": "...", "parent_mail_id": "...",
|
||||
"depth": -3,
|
||||
"from_name": "admin", "to_name": "pi",
|
||||
"subject": "...", "body_preview": "正文前 240 字节…",
|
||||
"attachment_count": 2,
|
||||
"detached": true, "parent_hidden": true
|
||||
}
|
||||
],
|
||||
"total": 21, "hidden": 4,
|
||||
"has_more_up": true, "has_more_down": false,
|
||||
"next_up": 20, "next_down": 20
|
||||
}
|
||||
```
|
||||
|
||||
- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
分块加载时根可能还没取到,所以不用「距根深度」
|
||||
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
|
||||
原样回传即可,不必自己算已加载数量
|
||||
- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
|
||||
- `hidden` = 本页因权限被过滤掉的节点数
|
||||
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
|
||||
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
|
||||
- `limit` 夹到 [1, 200],非法值回落默认 40
|
||||
|
||||
**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。
|
||||
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。
|
||||
|
||||
### 会话
|
||||
|
||||
```
|
||||
GET /me/sessions 我参与的会话
|
||||
GET /sessions/{id} 会话详情
|
||||
GET /sessions/{id}/mails 会话内邮件(含附件)
|
||||
PUT /sessions/{id}/alias 改会话别名(冲突 409)
|
||||
|
||||
GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null)
|
||||
POST /sessions/{id}/rename-proposal/dismiss 驳回建议
|
||||
|
||||
GET /sessions/{id}/budget 本任务的往返预算
|
||||
PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?}
|
||||
```
|
||||
|
||||
### 往返预算
|
||||
|
||||
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
|
||||
只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。
|
||||
所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。
|
||||
|
||||
```bash
|
||||
# 派活时给 3 个来回
|
||||
curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}'
|
||||
|
||||
# 看着往来内容决定加到 5
|
||||
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"max_rounds":5}'
|
||||
|
||||
# 加到 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` 开一串会话每条都是全新预算,全局上限形同虚设
|
||||
- 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 ——
|
||||
那次往返实际上没有发生
|
||||
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
|
||||
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
|
||||
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
|
||||
|
||||
### Agent 提议改会话别名
|
||||
|
||||
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
|
||||
Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
|
||||
|
||||
Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的
|
||||
HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
|
||||
**从入库正文里剥掉标记**并记在该封邮件上。
|
||||
|
||||
```bash
|
||||
curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN"
|
||||
# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}}
|
||||
```
|
||||
|
||||
- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409)
|
||||
- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议
|
||||
- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里
|
||||
- Agent 之后提**别的**名字会重新出现;同一个名字不会
|
||||
|
||||
会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名,
|
||||
`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** ——
|
||||
否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。
|
||||
标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。
|
||||
|
||||
### 联系人
|
||||
|
||||
```
|
||||
GET /contacts 联系人 = 一条 name@path.session 地址
|
||||
GET /contacts/suggest 三段式补全(?name=&path=)
|
||||
POST /contacts/archive 归档
|
||||
```
|
||||
|
||||
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
|
||||
给 `name`+`path` 返回已有会话别名与 `new`。
|
||||
|
||||
### 权限决策
|
||||
|
||||
```
|
||||
GET /permission/pending 待我决策的请求
|
||||
POST /permission/decide 决策 {mail_id, decision, note}
|
||||
```
|
||||
|
||||
### 附件
|
||||
|
||||
```
|
||||
POST /me/attachments 上传(multipart,字段名 file)→ attachment_id
|
||||
GET /me/attachments/{id} 下载
|
||||
DELETE /me/attachments/{id} 删除(仅未随邮件发出的)
|
||||
```
|
||||
|
||||
上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。
|
||||
未随邮件发出的附件 24 小时后由 GC 清理。
|
||||
|
||||
内容按 sha256 内容寻址:同内容重复上传不占额外空间。
|
||||
下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`,
|
||||
绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。
|
||||
|
||||
单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。
|
||||
|
||||
### 账号与密钥
|
||||
|
||||
```
|
||||
GET /auth/me 当前用户
|
||||
POST /auth/password 改密码
|
||||
POST /me/keys 创建客户端密钥
|
||||
GET /me/keys 我的密钥(只给 token_hint)
|
||||
DELETE /me/keys/{id} 吊销
|
||||
```
|
||||
|
||||
### 管理员(role=admin)
|
||||
|
||||
```
|
||||
GET|POST /admin/users 用户管理
|
||||
PUT|DELETE /admin/users/{id}
|
||||
POST /admin/users/{id}/reset 重置密码
|
||||
GET /admin/scopes 可选的 Agent/路径范围
|
||||
|
||||
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}
|
||||
```
|
||||
|
||||
## 四、Agent 接口
|
||||
|
||||
```
|
||||
POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
|
||||
POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota
|
||||
POST /mail/send 发信(扣配额)
|
||||
GET /mail/inbox 收件箱(含附件清单)
|
||||
POST /mail/{id}/forward 转发(扣配额)
|
||||
POST /permission/request 请求人类决策
|
||||
POST /attachments 上传附件
|
||||
GET /attachments/{id} 下载附件
|
||||
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
|
||||
```
|
||||
|
||||
发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。
|
||||
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
|
||||
|
||||
### 免配额通道:harness 代劳的转发
|
||||
|
||||
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
|
||||
|
||||
| relay | 上游 | 为什么免费 |
|
||||
|---|---|---|
|
||||
| `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 |
|
||||
| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
|
||||
|
||||
```bash
|
||||
# 转发本轮总结(relay_key = opencode 的 assistant message id)
|
||||
curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
|
||||
-d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透",
|
||||
"relay":"summary","relay_key":"msg_abc123"}'
|
||||
```
|
||||
|
||||
- `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串)
|
||||
- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来;
|
||||
唯一约束保证同一条上游消息只能免费转一次
|
||||
- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
|
||||
插件重试与 SSE 重放是正常现象,不是故障
|
||||
- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了
|
||||
- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等,
|
||||
它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」)
|
||||
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
|
||||
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
|
||||
|
||||
`max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。
|
||||
注意 Agent 全局配额与会话往返预算是两层,都要过。
|
||||
|
||||
## 五、实时推送(SSE)
|
||||
|
||||
```
|
||||
GET /events/stream
|
||||
```
|
||||
|
||||
事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。
|
||||
|
||||
按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
|
||||
不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
|
||||
|
||||
```js
|
||||
// 浏览器:Cookie 模式
|
||||
new EventSource('/api/v1/events/stream', { withCredentials: true });
|
||||
|
||||
// 浏览器:密钥模式(EventSource 不能带头)
|
||||
new EventSource(`/api/v1/events/stream?access_token=${token}`);
|
||||
```
|
||||
|
||||
```bash
|
||||
# 非浏览器客户端:用请求头
|
||||
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## 六、错误约定
|
||||
|
||||
失败响应统一为 `{"error": "中文可操作描述"}`,状态码:
|
||||
|
||||
| 码 | 含义 |
|
||||
|----|------|
|
||||
| 400 | 请求体或地址格式非法 |
|
||||
| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 |
|
||||
| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) |
|
||||
| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) |
|
||||
| 409 | 冲突(别名被占用、附件已随其他邮件发出) |
|
||||
| 413 | 附件超过大小上限 |
|
||||
| 429 | 登录失败次数过多(响应含 `retry_after` 秒) |
|
||||
|
||||
## 七、跨域
|
||||
|
||||
`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。
|
||||
已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length`
|
||||
(前者是附件下载取文件名所必需)。
|
||||
|
||||
WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。
|
||||
|
||||
## 八、前端如何指向不同后端
|
||||
|
||||
WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`:
|
||||
|
||||
```js
|
||||
// 构建期
|
||||
VITE_API_BASE=https://mail.example.com/api/v1 npm run build
|
||||
|
||||
// 运行时(同一份产物部署到不同后端)
|
||||
window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1';
|
||||
window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
|
||||
```
|
||||
|
||||
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。
|
||||
796
docs/MVP-SPEC.md
Normal file
796
docs/MVP-SPEC.md
Normal file
@ -0,0 +1,796 @@
|
||||
# 邮件驱动·多智能体协作平台 — MVP 技术规格书
|
||||
|
||||
> 基于 v2.0 完整设计文档,聚焦最小可用闭环。
|
||||
> 版本:v0.1 | 日期:2026-09-01
|
||||
|
||||
---
|
||||
|
||||
## 一、MVP 范围定义
|
||||
|
||||
### 1.1 核心目标
|
||||
|
||||
**跑通一个完整的人-Agent协作闭环:**
|
||||
|
||||
```
|
||||
人类发任务 → Agent 收到 → Agent 执行 → Agent 遇到需要人决策的事 → 请求权限 → 人类批准 → Agent 继续执行 → Agent 回复结果
|
||||
```
|
||||
|
||||
### 1.2 MVP 包含(✅)与不包含(❌)
|
||||
|
||||
| 功能 | MVP | 说明 |
|
||||
|------|-----|------|
|
||||
| 邮件收发(人↔Agent) | ✅ | 核心闭环 |
|
||||
| 三维寻址(name@path.session) | ✅ | 核心寻址范式;session 位三态:省略=默认会话 / `new`=新建 / 别名=必须已存在(否则无法送达) |
|
||||
| 会话别名(命名与改名) | ✅ | 别名负责寻址,全局唯一;`new` 为保留字 |
|
||||
| 别名/标题复用 Agent 平台命名 | ✅ | 平台(如 opencode)由模型生成会话摘要标题 + slug,经 `POST /sessions/:id/sync` 回写;撞名自动加 `-2` 后缀 |
|
||||
| 密钥认证(Agent / 用户分离) | ✅ | agent_keys 用于注册/心跳/SSE;user_keys 仅用于 /me/*;三种生命周期 permanent/one_time/timed |
|
||||
| 内置 SQLite(外部库可选) | ✅ | 默认零依赖;`DATABASE_URL` 非空时切 PostgreSQL |
|
||||
| systemd 一键部署 | ✅ | `deploy/install.sh`,产物为一个二进制 + 一个 .db |
|
||||
| 附件 | ✅ | 内容寻址存盘(sha256 去重),上传与发信两步;下载强制 octet-stream |
|
||||
| 转发 | ✅ | 引用原文 + 附件随行;只能转发自己参与过的邮件 |
|
||||
| Agent 发信配额 | ✅ | 只限发信不限收信;剩余次数随响应与心跳回传 |
|
||||
| WebAPI 等价接入 | ✅ | WebUI 与第三方客户端同一套 API,见 docs/API.md |
|
||||
| 对话树 | ✅ | 沿 parent_mail_id 递归展开,跨会话,按方向分块加载,不建 tree_nodes 表 |
|
||||
| Agent 提议改会话名 | ✅ | 正文里的 HTML 注释标记,入库时剥除;改名需用户确认 |
|
||||
| 会话往返预算 | ✅ | 写信时给 / 对话页随时改;与 Agent 全局配额两层都要过 |
|
||||
| 插件自动转发 | ✅ | 平台原生权限询问 + 本轮最终总结;**不消耗配额** |
|
||||
| 会话管理(创建/列表/状态) | ✅ | 会话是协作的边界 |
|
||||
| 权限请求与决策 | ✅ | Agent 需要人批准才能继续 |
|
||||
| Agent 注册与发现 | ✅ | 最小 Registry |
|
||||
| 前端收件箱/发件箱列表 | ✅ | 基础 UI |
|
||||
| 前端新建邮件/回复 | ✅ | 基础 UI |
|
||||
| 邮件正文 Markdown 渲染 | ✅ | Agent 输出多为 MD |
|
||||
| Agent 桥接插件 | ✅ | opencode 为第一个接入平台(`plugins/opencode-mail-bridge`) |
|
||||
| 对话树 | ❌ | 后续迭代 |
|
||||
| 抄送(CC) | ✅ | 已实现:`cc_list` + 收件箱抄送可见 |
|
||||
| 转发 | ❌ | 后续迭代 |
|
||||
| 配额机制 | ❌ | 后续迭代 |
|
||||
| 会话别名命名/改名 | ✅ | 发信时命名、事后改名、平台命名自动同步 |
|
||||
| Agent 正文里主动提议改名 | ❌ | 后续迭代 |
|
||||
| 工作列表/卡片视图 | ❌ | 后续迭代 |
|
||||
| DeepSeek Harness 插件 | ❌ | 后续迭代 |
|
||||
| 跨主机 Agent 发现 | ❌ | 后续迭代 |
|
||||
|
||||
---
|
||||
|
||||
## 二、系统简化架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 前端(Web UI) │
|
||||
│ 收件箱 / 发件箱 / 新建邮件 / 回复 │
|
||||
│ Markdown 编辑器 + 渲染器 │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ HTTP REST + SSE
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Mail Gateway(单体服务) │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
|
||||
│ │ Mail API │ │ Registry │ │ Session Mgr │ │
|
||||
│ │ 收发路由 │ │ 注册/发现 │ │ 会话/状态 │ │
|
||||
│ └───────────┘ └───────────┘ └───────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Agent 通信层(WebSocket) │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Agent 侧(Pi Agent + 插件) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ pi-mail-bridge 插件 │ │
|
||||
│ │ - 注册 send_mail / read_inbox / req_perm │ │
|
||||
│ │ - WebSocket 连接 Gateway │ │
|
||||
│ │ - 新邮件注入 Agent 上下文 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ Pi Agent 本体(文件读写、终端、Git、推理) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SQLite(默认) │ 邮件 + 会话持久化
|
||||
│ 或外部 PostgreSQL│ DATABASE_URL 指定
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**简化要点:**
|
||||
- Gateway 和 Registry 合并为单体服务
|
||||
- 前端用 SSE(Server-Sent Events)替代 WebSocket(更简单,单向推送够用)
|
||||
- 数据库默认内置 SQLite(零外部依赖,与 go:embed 的前端一起构成「一个二进制 + 一个 .db」);
|
||||
`DATABASE_URL` 非空时切换到外部 PostgreSQL。方言差异收在 `internal/db`,repo 层只写一份 SQL
|
||||
- SSE 推送不依赖数据库通知机制(无需 Redis 或 PG LISTEN/NOTIFY):单体进程内 `sse.Manager`
|
||||
按收件人名分流,Agent 通道与人类用户通道共用一套投递
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
以下 DDL 以 PostgreSQL 方言书写。SQLite 侧结构与语义完全一致,仅方言不同
|
||||
(`UUID`→`TEXT`、`TIMESTAMPTZ`→`DATETIME`、`JSONB`→`TEXT`、`VARCHAR(n)`→`TEXT`),
|
||||
见 `gateway/internal/db/migrations/init_sqlite.sql`。
|
||||
|
||||
### 3.1 agents 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_name VARCHAR(64) NOT NULL UNIQUE, -- 全局唯一
|
||||
secret VARCHAR(128) NOT NULL, -- 注册密钥
|
||||
host_url VARCHAR(256) NOT NULL, -- 插件通信地址(ws://...)
|
||||
workspaces JSONB NOT NULL DEFAULT '[]', -- [{name, path}]
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'pi', -- pi / dsh / cline
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline', -- online / offline
|
||||
max_rounds INT NOT NULL DEFAULT 10, -- 通信配额(Phase 2 启用)
|
||||
used_rounds INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 sessions 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128), -- 会话别名(如 add-health-check),全局唯一,负责寻址
|
||||
from_agent VARCHAR(64) NOT NULL, -- 发起者 agent_name(或 'human')
|
||||
subject VARCHAR(512) NOT NULL, -- 会话主题
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active', -- active / waiting / completed
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||
|
||||
-- 别名负责三维寻址 name@path.<alias>,必须唯一;未命名会话(NULL)不受约束
|
||||
CREATE UNIQUE INDEX idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
```
|
||||
|
||||
### 3.3 mails 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE mails (
|
||||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id UUID REFERENCES mails(mail_id), -- 回复链(线性,非树)
|
||||
|
||||
from_name VARCHAR(64) NOT NULL, -- 发件人 agent_name(或 'human')
|
||||
from_workspace VARCHAR(128), -- 发件人工作区
|
||||
to_name VARCHAR(64) NOT NULL, -- 收件人 agent_name(或 'human')
|
||||
to_workspace VARCHAR(128), -- 收件人工作区
|
||||
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
body TEXT NOT NULL, -- Markdown
|
||||
|
||||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal', -- normal / permission_request
|
||||
permission_options JSONB, -- 权限请求的选项 ["同意","拒绝"]
|
||||
permission_result VARCHAR(32), -- approved / rejected / null
|
||||
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unread', -- unread / read / archived
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 防循环(Phase 2 启用)
|
||||
hop_limit INT DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX idx_mails_parent ON mails(parent_mail_id);
|
||||
```
|
||||
|
||||
### 3.4 设计决策说明
|
||||
|
||||
| 决策 | 理由 |
|
||||
|------|------|
|
||||
| `parent_mail_id` 而非 `tree_node_id` | MVP 只有线性回复链,不需要树结构。Phase 2 引入 CC/转发后再加 tree 节点表 |
|
||||
| `from_name` 用字符串而非 UUID | MVP 阶段 agent_name 全局唯一,简化查询。Phase 2 可加 agent_id 外键 |
|
||||
| `permission_options` 存 JSONB | 允许每个权限请求自定义选项,前端动态渲染按钮 |
|
||||
| 不单独建 `conversations` 表 | session 已经承载会话概念,不重复建设 |
|
||||
|
||||
---
|
||||
|
||||
## 四、API 设计
|
||||
|
||||
### 4.1 基础信息
|
||||
|
||||
- **Base URL**: `http://gateway:8080/api/v1`
|
||||
- **认证**: Agent 侧用 `X-Agent-Name` + `X-Agent-Secret` header;前端用 session cookie
|
||||
- **内容类型**: `application/json`
|
||||
|
||||
### 4.2 Agent 注册与心跳
|
||||
|
||||
```
|
||||
POST /agent/register
|
||||
Body: { name, secret, workspaces: [{name, path}], platform }
|
||||
Response: { agent_id, status: "registered" }
|
||||
|
||||
POST /agent/heartbeat
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { status: "ok", pending_mails: 3 }
|
||||
```
|
||||
|
||||
心跳响应中 `pending_mails` 告诉插件有多少未读邮件需要拉取。
|
||||
|
||||
### 4.3 邮件收发
|
||||
|
||||
```
|
||||
POST /mail/send
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Body: {
|
||||
to: "builder@ModelRouter", -- name@workspace 格式
|
||||
subject: "请为 ModelRouter 增加健康检查接口",
|
||||
body: "## 需求\n\n...",
|
||||
session_alias: "add-health-check", -- 可选,新建会话时用
|
||||
reply_to: "uuid" -- 可选,回复某封邮件
|
||||
}
|
||||
Response: { mail_id, session_id }
|
||||
|
||||
GET /mail/inbox
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Query: ?status=unread&limit=10
|
||||
Response: {
|
||||
mails: [
|
||||
{
|
||||
mail_id, session_id, session_alias,
|
||||
from_name, from_workspace, subject,
|
||||
body_preview, mail_type, status, created_at
|
||||
}
|
||||
],
|
||||
total: 5
|
||||
}
|
||||
|
||||
GET /mail/:mail_id
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { ... 完整邮件字段 ... }
|
||||
|
||||
POST /mail/:mail_id/read
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { status: "read" }
|
||||
```
|
||||
|
||||
### 4.4 权限请求
|
||||
|
||||
```
|
||||
POST /permission/request
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Body: {
|
||||
to: "human", -- 固定为 human
|
||||
question: "是否允许合并 PR #42?",
|
||||
options: ["同意合并", "拒绝,需要修改"],
|
||||
context: "PR 改动了 3 个文件,CI 全部通过...",
|
||||
session_id: "uuid" -- 可选,关联现有会话
|
||||
}
|
||||
Response: { mail_id, session_id, permission_mail_id }
|
||||
|
||||
POST /permission/decide
|
||||
Body: {
|
||||
mail_id: "uuid",
|
||||
decision: "同意合并", -- 必须是 request 中 options 之一
|
||||
note: "可以合并,但请补充测试" -- 可选备注
|
||||
}
|
||||
Response: { status: "decided" }
|
||||
```
|
||||
|
||||
### 4.5 会话与 Agent 列表
|
||||
|
||||
```
|
||||
GET /sessions
|
||||
Query: ?status=active&limit=20
|
||||
Response: {
|
||||
sessions: [
|
||||
{
|
||||
session_id, session_alias, subject,
|
||||
from_agent, status, created_at, updated_at,
|
||||
mail_count: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
GET /sessions/:session_id
|
||||
Response: {
|
||||
session: { ... },
|
||||
mails: [ ... 按时间排序的邮件列表 ... ]
|
||||
}
|
||||
|
||||
GET /agents
|
||||
Query: ?status=online
|
||||
Response: {
|
||||
agents: [
|
||||
{ agent_name, workspaces: [...], platform, status }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 SSE 推送(替代 WebSocket)
|
||||
|
||||
```
|
||||
GET /events/stream
|
||||
Header: X-Agent-Name (Agent 侧) 或 Cookie (前端)
|
||||
Query: ?agent_name=builder (Agent 侧)
|
||||
或无参数 (前端,推送所有相关事件)
|
||||
|
||||
Event: new_mail
|
||||
Data: { mail_id, session_id, from_name, subject, mail_type }
|
||||
|
||||
Event: permission_decision
|
||||
Data: { mail_id, decision, note }
|
||||
|
||||
Event: session_update
|
||||
Data: { session_id, status, updated_at }
|
||||
|
||||
Event: agent_online
|
||||
Data: { agent_name, status }
|
||||
```
|
||||
|
||||
**为什么选 SSE 而不是 WebSocket:**
|
||||
- 前端只需要接收推送,不需要双向通信
|
||||
- SSE 基于 HTTP,穿透代理/防火墙更容易
|
||||
- 浏览器原生支持 EventSource,无需第三方库
|
||||
- Agent 侧用 WebSocket(需要双向),前端用 SSE(只需单向)
|
||||
|
||||
---
|
||||
|
||||
## 五、Pi Agent 桥接插件设计
|
||||
|
||||
### 5.1 插件能力(基于 Pi 真实 API)
|
||||
|
||||
Pi Agent 的插件机制通过 `pi install` 安装,插件可以:
|
||||
- 注册自定义 tool(工具)
|
||||
- 在 session 生命周期钩子中执行逻辑
|
||||
- 通过 HTTP 与外部服务通信
|
||||
|
||||
### 5.2 插件架构
|
||||
|
||||
```
|
||||
pi-mail-bridge/
|
||||
├── index.js # 插件入口 + 钩子拦截器注册
|
||||
├── tools/
|
||||
│ ├── send_mail.js # 发送邮件工具(Agent 主动调用)
|
||||
│ └── read_inbox.js # 读取收件箱工具(Agent 收到通知后调用)
|
||||
├── hooks/
|
||||
│ ├── request_permission.js # 拦截 request_permission,格式化权限邮件
|
||||
│ ├── ask_user.js # 拦截 ask_user,格式化提问邮件
|
||||
│ └── final_message.js # 拦截最终输出,格式化总结邮件
|
||||
├── transport/
|
||||
│ ├── sse.js # SSE 客户端(接收推送)
|
||||
│ └── http.js # HTTP 客户端(发送请求)
|
||||
├── config.js # 配置管理
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 5.3 核心区分:工具 vs 钩子拦截器
|
||||
|
||||
**重要设计原则:**
|
||||
|
||||
| 类型 | 内容 | 谁来触发 |
|
||||
|------|------|----------|
|
||||
| Agent 可调用的工具 | `send_mail`、`read_inbox` | Agent 主动调用 |
|
||||
| 自动触发器(钩子) | 权限请求、提问、最终总结 | 平台钩子自动拦截 |
|
||||
|
||||
**邮箱不直接注入 Agent**:新邮件到达时,插件只向 Agent 注入一条**通知**(来自谁、主题是什么),Agent 看到通知后自行调用 `read_inbox` 工具拉取邮件正文。邮件全文从不直接塞进 Agent 上下文。
|
||||
|
||||
**三种自动转邮件触发器**(钩子拦截,非 Agent 调用工具):
|
||||
|
||||
| 触发场景 | Agent 行为 | 插件钩子动作 | 邮件特征 |
|
||||
|----------|-----------|--------------|----------|
|
||||
| Agent 请求权限 | 调用 `request_permission()` | 冻结 Agent,格式化 `[权限请求]` 邮件发至人类 | `mail_type=permission_request`,正文自动渲染 ✅❌ 按钮 |
|
||||
| Agent 提问人类 | 调用 `ask_user(question)` | 冻结 Agent,格式化 `[需回复]` 邮件发至人类 | subject 含 `[需回复]`,正文=问题 |
|
||||
| Agent 最后一条消息 | 完成所有任务/配额用尽 | 拦截输出,格式化 `[最终总结]` 邮件发至人类 | subject 含 `[最终总结]`,Agent 进入休眠 |
|
||||
|
||||
### 5.4 工具注册(Agent 可调用)
|
||||
|
||||
```javascript
|
||||
// tools/send_mail.js
|
||||
module.exports = {
|
||||
name: "send_mail",
|
||||
description: "发送邮件给指定 Agent 或人类",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: {
|
||||
type: "string",
|
||||
description: "收件人,格式 name@workspace(如 builder@ModelRouter)"
|
||||
},
|
||||
subject: {
|
||||
type: "string",
|
||||
description: "邮件主题"
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "邮件正文,支持 Markdown 格式"
|
||||
},
|
||||
session_alias: {
|
||||
type: "string",
|
||||
description: "会话别名(新建会话时使用)"
|
||||
}
|
||||
},
|
||||
required: ["to", "subject", "body"]
|
||||
},
|
||||
async execute(params, ctx) {
|
||||
const { to, subject, body, session_alias } = params;
|
||||
const [toName, toWorkspace] = to.split("@");
|
||||
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to_name: toName,
|
||||
to_workspace: toWorkspace || null,
|
||||
subject,
|
||||
body,
|
||||
session_alias: session_alias || null,
|
||||
reply_to: ctx.currentMailId || null // 如果是在回复某封邮件
|
||||
});
|
||||
|
||||
return `✅ 邮件已发送。Mail ID: ${result.mail_id},会话: ${result.session_id}`;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tools/read_inbox.js
|
||||
module.exports = {
|
||||
name: "read_inbox",
|
||||
description: "查阅收件箱中的邮件。收到新邮件通知后调用此工具查看完整内容。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filter: {
|
||||
type: "string",
|
||||
enum: ["unread", "all"],
|
||||
description: "过滤条件,默认 unread"
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "返回数量,默认 5"
|
||||
}
|
||||
}
|
||||
},
|
||||
async execute(params, ctx) {
|
||||
const { filter = "unread", limit = 5 } = params;
|
||||
const result = await ctx.http.get("/api/v1/mail/inbox", {
|
||||
params: { status: filter, limit }
|
||||
});
|
||||
|
||||
if (result.mails.length === 0) {
|
||||
return "📭 收件箱为空。";
|
||||
}
|
||||
|
||||
return result.mails.map(m =>
|
||||
`📧 [${m.status}] ${m.from_name}: ${m.subject}\n` +
|
||||
` 会话: #${m.session_alias || '未命名'} | ${m.created_at}\n` +
|
||||
` 预览: ${m.body_preview?.substring(0, 100)}...`
|
||||
).join("\n\n");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.5 钩子拦截器(自动触发器,非 Agent 工具)
|
||||
|
||||
三种场景下,插件通过平台钩子**自动拦截** Agent 行为,格式化为邮件发出。Agent 无需(也不应)手动调用 send_mail 来做这些事。
|
||||
|
||||
```javascript
|
||||
// hooks/request_permission.js
|
||||
module.exports = {
|
||||
name: "request_permission",
|
||||
description: "拦截 Agent 的 request_permission 调用,自动格式化为权限请求邮件",
|
||||
// 由平台钩子触发,非 Agent 调用
|
||||
async intercept(ctx, question, options = ["同意", "拒绝"], context = "") {
|
||||
// 1. 调 Gateway 创建权限请求邮件
|
||||
const result = await ctx.http.post("/api/v1/permission/request", {
|
||||
question,
|
||||
options,
|
||||
context,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. 冻结 Agent,标记等待状态
|
||||
ctx.setWaitingForPermission(result.permission_mail_id);
|
||||
|
||||
// 3. 返回挂起状态给 Agent(Agent 不再继续执行)
|
||||
return { suspended: true, wait_type: "permission" };
|
||||
}
|
||||
};
|
||||
|
||||
// hooks/ask_user.js
|
||||
module.exports = {
|
||||
name: "ask_user",
|
||||
description: "拦截 Agent 的 ask_user 调用,自动格式化为提问邮件",
|
||||
async intercept(ctx, question) {
|
||||
// 1. 调 Gateway 发送普通邮件(主题自动加 [需回复])
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to: "human",
|
||||
subject: `[需回复] ${question.substring(0, 100)}`,
|
||||
body: question,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. 冻结 Agent,标记等待状态
|
||||
ctx.setWaitingForReply(result.mail_id);
|
||||
|
||||
return { suspended: true, wait_type: "reply" };
|
||||
}
|
||||
};
|
||||
|
||||
// hooks/final_message.js
|
||||
module.exports = {
|
||||
name: "final_message",
|
||||
description: "拦截 Agent 最后一条消息,格式化为最终总结邮件",
|
||||
async intercept(ctx, message) {
|
||||
// 1. 调 Gateway 发送最终总结邮件(主题自动加 [最终总结])
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to: "human",
|
||||
subject: `[最终总结] ${ctx.sessionAlias || '任务完成'}`,
|
||||
body: message,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. Agent 进入休眠
|
||||
ctx.enterSleepMode();
|
||||
|
||||
return { suspended: true, wait_type: "sleep" };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.6 插件生命周期
|
||||
|
||||
```javascript
|
||||
// index.js
|
||||
const { registerTools, startSSE, registerAgent } = require('./transport');
|
||||
const config = require('./config');
|
||||
|
||||
module.exports = {
|
||||
async onSessionStart(ctx) {
|
||||
// 1. 向 Gateway 注册
|
||||
await registerAgent({
|
||||
name: config.agentName,
|
||||
secret: config.agentSecret,
|
||||
workspaces: config.workspaces,
|
||||
platform: 'pi'
|
||||
});
|
||||
|
||||
// 2. 启动 SSE 监听新邮件
|
||||
startSSE(config.gatewayUrl, config.agentName, (event) => {
|
||||
if (event.type === 'new_mail') {
|
||||
// 注入系统消息到 Agent 上下文
|
||||
ctx.addSystemMessage(
|
||||
`📬 新邮件(${event.data.mail_type})\n` +
|
||||
`来自: ${event.data.from_name}\n` +
|
||||
`主题: ${event.data.subject}\n` +
|
||||
`会话: #${event.data.session_alias || '未命名'}\n` +
|
||||
`使用 read_inbox 工具查看详情。`
|
||||
);
|
||||
}
|
||||
if (event.type === 'permission_decision') {
|
||||
// 恢复 Agent 执行
|
||||
ctx.resumeFromPermission(event.data.mail_id, event.data.decision);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 定期心跳(每30秒)
|
||||
setInterval(async () => {
|
||||
await ctx.http.post('/api/v1/agent/heartbeat');
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.7 权限等待的恢复机制
|
||||
|
||||
Agent 被钩子拦截并冻结后的恢复流程:
|
||||
|
||||
```
|
||||
1. Agent 调用 request_permission → 发邮件给 human → 标记等待
|
||||
2. 插件监听 SSE,收到 permission_decision 事件
|
||||
3. 插件调用 ctx.resumeFromPermission(mailId, decision)
|
||||
4. Pi Agent 恢复执行,decision 作为 tool_result 返回给 Agent
|
||||
5. Agent 根据 decision 继续后续操作
|
||||
```
|
||||
|
||||
**关键点:** Agent 进程不阻塞,只是标记为"等待权限"状态。SSE 收到决策后,通过 Pi Agent 的上下文注入机制恢复执行。
|
||||
|
||||
---
|
||||
|
||||
## 六、前端设计(MVP 简化版)
|
||||
|
||||
### 6.1 布局
|
||||
|
||||
MVP 阶段用**两栏布局**(去掉中间的工作列表/对话树):
|
||||
|
||||
```
|
||||
┌────────────────────┬────────────────────────────────────┐
|
||||
│ 左侧(300px) │ 右侧(剩余宽度) │
|
||||
├────────────────────┼────────────────────────────────────┤
|
||||
│ 📬 收件箱 (3) │ 当前查看的邮件正文 │
|
||||
│ 📤 发件箱 │ (Markdown 渲染) │
|
||||
│ 📝 草稿箱 │ │
|
||||
│ ✏️ 新建 │ ──── 分隔线 ──── │
|
||||
│ │ │
|
||||
│ ─── 邮件列表 ─── │ 回复编辑器 │
|
||||
│ │ 📧 邮件A [未读] │ [ Markdown 编辑器 ] │
|
||||
│ │ 📧 邮件B [权限] │ [发送] [取消] │
|
||||
│ │ 📧 邮件C │ │
|
||||
│ │ ... │ │
|
||||
└────────────────────┴────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 权限请求邮件的特殊渲染
|
||||
|
||||
当邮件类型为 `permission_request` 时,正文下方自动渲染按钮组:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 📧 权限请求 │
|
||||
│ 来自: @builder.ModelRouter │
|
||||
│ 主题: 是否允许合并 PR #42? │
|
||||
│ │
|
||||
│ ## 背景 │
|
||||
│ PR 改动了 3 个文件,CI 全部通过... │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ✅ 同意合并 │ │ ❌ 拒绝 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ [可选备注输入框] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 技术栈
|
||||
|
||||
- **框架**: React 18 + TypeScript
|
||||
- **样式**: TailwindCSS
|
||||
- **Markdown**: react-markdown + remark-gfm(支持表格、代码高亮)
|
||||
- **编辑器**: @uiw/react-md-editor(轻量 Markdown 编辑器)
|
||||
- **状态管理**: Zustand(简单够用)
|
||||
- **实时推送**: EventSource(SSE 原生 API)
|
||||
- **构建**: Vite
|
||||
|
||||
### 6.4 核心页面/组件
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx
|
||||
├── api/
|
||||
│ ├── client.ts # HTTP 客户端
|
||||
│ └── sse.ts # SSE 连接管理
|
||||
├── stores/
|
||||
│ ├── mailStore.ts # 邮件状态
|
||||
│ └── sessionStore.ts # 会话状态
|
||||
├── components/
|
||||
│ ├── Sidebar/
|
||||
│ │ ├── Sidebar.tsx # 左侧图标栏
|
||||
│ │ ├── MailList.tsx # 邮件列表
|
||||
│ │ └── MailItem.tsx # 单封邮件条目
|
||||
│ ├── MailView/
|
||||
│ │ ├── MailView.tsx # 右侧邮件正文
|
||||
│ │ ├── MailBody.tsx # Markdown 渲染
|
||||
│ │ ├── PermissionCard.tsx # 权限请求卡片
|
||||
│ │ └── ReplyEditor.tsx # 回复编辑器
|
||||
│ └── Compose/
|
||||
│ └── ComposeModal.tsx # 新建邮件弹窗
|
||||
└── types/
|
||||
└── index.ts # TypeScript 类型定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、完整 MVP 工作流(端到端)
|
||||
|
||||
### 场景:人类要求 Builder Agent 增加健康检查接口
|
||||
|
||||
```
|
||||
步骤 1: 人类在前端新建邮件
|
||||
- 收件人: @builder.ModelRouter
|
||||
- 主题: 为 ModelRouter 增加健康检查接口
|
||||
- 正文: ## 需求描述\n\n请为 ModelRouter 的 HTTP 服务增加 /health 端点...
|
||||
- 会话别名: add-health-check
|
||||
→ 前端 POST /api/v1/mail/send
|
||||
→ Gateway 创建 session + mail,通过 SSE 推送 new_mail 给 Builder
|
||||
|
||||
步骤 2: Builder Agent 收到通知
|
||||
- Pi Agent 收到系统消息: "📬 新邮件...使用 read_inbox 查看"
|
||||
- Agent 调用 read_inbox 工具
|
||||
- Gateway 返回邮件详情
|
||||
→ Agent 读取正文,理解需求
|
||||
|
||||
步骤 3: Builder Agent 执行任务
|
||||
- Agent 调用终端: git checkout -b feature/add-health-check
|
||||
- Agent 调用文件读写: 编写 health check 代码
|
||||
- Agent 调用终端: 运行测试
|
||||
- Agent 调用终端: git commit, git push
|
||||
|
||||
步骤 4: Builder Agent 请求权限(如需要)
|
||||
- Agent 调用 request_permission: "是否允许合并 PR #42?"
|
||||
- Gateway 发送权限请求邮件给人类
|
||||
→ 前端收到 SSE 推送,显示权限请求卡片
|
||||
|
||||
步骤 5: 人类决策
|
||||
- 人类点击 "✅ 同意合并"
|
||||
- 前端 POST /api/v1/permission/decide
|
||||
→ Gateway 发送 permission_decision SSE 给 Builder
|
||||
|
||||
步骤 6: Builder Agent 恢复执行
|
||||
- 插件收到 permission_decision
|
||||
- Agent 恢复执行,得到 "同意合并" 的结果
|
||||
- Agent 执行 gh pr merge
|
||||
- Agent 调用 send_mail: "PR 已合并,健康检查接口已就绪"
|
||||
→ 人类收到完成通知
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、MVP 实施计划
|
||||
|
||||
### Week 1:后端核心
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| schema 建表(两方言各一份) | `internal/db/migrations/` |
|
||||
| Gateway HTTP API(mail CRUD + session) | 可用的 REST API |
|
||||
| Agent 注册/心跳 API | Agent 可注册并保持在线 |
|
||||
| SSE 推送机制 | /events/stream 端点 |
|
||||
| 权限请求/决策 API | 完整的权限闭环 |
|
||||
|
||||
### Week 2:Pi 插件 + 前端骨架
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| pi-mail-bridge 插件框架 | pi install 可用 |
|
||||
| send_mail / read_inbox 工具 | Agent 可收发邮件 |
|
||||
| request_permission 工具 | Agent 可请求权限 |
|
||||
| 前端两栏布局 + 路由 | 可访问的 Web UI |
|
||||
| 邮件列表 + 正文渲染 | 可查看邮件 |
|
||||
| 新建邮件 + 回复编辑器 | 可发送邮件 |
|
||||
|
||||
### Week 3:闭环联调
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| 人→Agent→人 完整闭环测试 | 端到端可演示 |
|
||||
| 权限请求闭环测试 | Agent 等待→人批准→Agent 恢复 |
|
||||
| SSE 实时推送验证 | 新邮件到达时 UI 自动更新 |
|
||||
| 错误处理与边界情况 | 网络断开、Agent 离线等 |
|
||||
| 基础部署脚本 | `deploy/install.sh` + systemd 单元(数据库为内置 SQLite,无需容器编排) |
|
||||
|
||||
---
|
||||
|
||||
## 九、关键设计决策记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Gateway + Registry 合并 | ✅ 合并为单体 | MVP 阶段单机部署,减少运维复杂度 |
|
||||
| 前端推送用 SSE | ✅ SSE | 单向推送够用,比 WebSocket 简单 |
|
||||
| 会话内邮件链用线性回复 | ✅ parent_mail_id | 不需要树结构,线性链够用 |
|
||||
| Agent 等待权限不阻塞进程 | ✅ 非阻塞 | Agent 进程不能挂起,用状态标记 + SSE 恢复 |
|
||||
| 第一个插件选 Pi Agent | ✅ Pi | 团队更熟悉,API 更直接 |
|
||||
| 不用 Redis | ✅ PG LISTEN/NOTIFY | MVP 减少依赖,PG 原生支持发布订阅 |
|
||||
| 前端不用对话树 | ✅ 两栏布局 | Phase 1 聚焦核心闭环 |
|
||||
|
||||
---
|
||||
|
||||
## 十、后续迭代路线
|
||||
|
||||
### 下一批(MVP 已完成的部分不再列出)
|
||||
- 对话树数据模型 + 前端渲染
|
||||
- 转发功能(抄送已完成)
|
||||
- 工作列表卡片视图(中间栏)
|
||||
- Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成)
|
||||
- DeepSeek Harness 插件
|
||||
|
||||
### Phase 3(Phase 2 后 2 周)
|
||||
- 配额机制
|
||||
- 跨主机 Agent 发现(Gateway + Registry 拆分)
|
||||
- Agent 间通信签名验证
|
||||
- Hop-limit 防循环
|
||||
|
||||
### Phase 4(持续)
|
||||
- 监控面板
|
||||
- 审计日志查询
|
||||
- 智能路由推荐
|
||||
- Agent 自动会话别名建议
|
||||
|
||||
---
|
||||
|
||||
文档版本:v0.1 (MVP)
|
||||
基于:完整设计文档 v2.0
|
||||
日期:2026-09-01
|
||||
1207
docs/PLAN.md
Normal file
1207
docs/PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
306
gateway/cmd/server/main.go
Normal file
306
gateway/cmd/server/main.go
Normal file
@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/blob"
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/handler"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/static"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.Connect(ctx, cfg.DatabaseURL); err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 第一轮迁移:建表(users 必须先存在,'human' 数据迁移才能找到管理员)
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 确保存在默认管理员
|
||||
bootstrapAdmin(ctx, cfg)
|
||||
|
||||
// 第二轮迁移:此时管理员已存在,历史 'human' 字面量得以重写
|
||||
// (仅 PostgreSQL 有该历史包袹;SQLite 是新后端,这一轮是幂等的建表重跑)
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
log.Fatalf("Post-admin migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 附件存储:内容存盘,数据库只存元数据
|
||||
blobs, err := blob.New(cfg.AttachmentDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Attachment store failed: %v", err)
|
||||
}
|
||||
handler.Blobs = blobs
|
||||
fmt.Printf("附件存储:%s(单个上限 %.0f MB)\n",
|
||||
blobs.Root(), float64(cfg.MaxAttachmentBytes)/(1<<20))
|
||||
|
||||
// 后台 GC:清掉上传后未随邮件发出的孤立附件,
|
||||
// 否则取消发信与 Agent 崩溃留下的文件会让磁盘单调增长。
|
||||
go sweepOrphanAttachments(blobs)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.CORSOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Agent-Name", "X-Agent-Secret"},
|
||||
// 附件下载靠 Content-Disposition 拿文件名;不暂存就拿不到。
|
||||
// Content-Length 给进度条用。
|
||||
ExposedHeaders: []string{"Link", "Content-Disposition", "Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// ---- 首次初始化(公开;仅系统无用户时可用) ----
|
||||
r.Get("/setup/status", handler.SetupStatus)
|
||||
r.Post("/setup/admin", handler.SetupAdmin)
|
||||
|
||||
// ---- 认证(公开) ----
|
||||
r.Post("/auth/login", handler.Login)
|
||||
r.Post("/auth/logout", handler.Logout)
|
||||
|
||||
// ---- Agent 注册(凭 secret,非人类登录态) ----
|
||||
r.Post("/agent/register", handler.RegisterAgent)
|
||||
|
||||
// ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ----
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AgentAuth)
|
||||
r.Post("/agent/heartbeat", handler.HeartbeatAgent)
|
||||
r.Post("/mail/send", handler.SendMail)
|
||||
r.Get("/mail/inbox", handler.GetInbox)
|
||||
r.Post("/mail/{id}/forward", handler.ForwardMail)
|
||||
r.Post("/permission/request", handler.RequestPermission)
|
||||
// 附件:先上传拿 id,再在发信时放进 attachment_ids
|
||||
r.Post("/attachments", handler.UploadAttachment)
|
||||
r.Get("/attachments/{id}", handler.DownloadAttachment)
|
||||
// 平台侧会话标题/slug 回写本侧(平台叫什么,本侧就叫什么)
|
||||
r.Post("/sessions/{id}/sync", handler.SyncSession)
|
||||
})
|
||||
|
||||
// ---- 人类登录态 ----
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.UserAuth)
|
||||
|
||||
r.Get("/auth/me", handler.Me)
|
||||
r.Post("/auth/password", handler.ChangePassword)
|
||||
|
||||
// 自己的邮箱
|
||||
r.Post("/me/mail/send", handler.MeSendMail)
|
||||
r.Get("/me/mail/inbox", handler.MeGetInbox)
|
||||
r.Get("/me/mail/sent", handler.MeGetSent)
|
||||
r.Get("/me/sessions", handler.MeGetSessions)
|
||||
r.Post("/me/mail/{id}/forward", handler.MeForwardMail)
|
||||
|
||||
// 附件
|
||||
r.Post("/me/attachments", handler.MeUploadAttachment)
|
||||
r.Delete("/me/attachments/{id}", handler.MeDeleteAttachment)
|
||||
|
||||
// 自己的客户端连接密钥(仅能用于 /me/* 与会话级接口,不可注册 Agent)
|
||||
r.Post("/me/keys", handler.CreateMyKey)
|
||||
r.Get("/me/keys", handler.ListMyKeys)
|
||||
r.Delete("/me/keys/{id}", handler.DeleteMyKey)
|
||||
|
||||
// 邮件/会话(带会话级鉴权)
|
||||
r.Get("/mail/{id}", handler.GetMail)
|
||||
r.Get("/mail/{id}/thread", handler.GetMailThread)
|
||||
r.Post("/mail/{id}/read", handler.MarkMailRead)
|
||||
r.Get("/sessions/{id}", handler.GetSession)
|
||||
r.Get("/sessions/{id}/mails", handler.GetSessionMails)
|
||||
r.Put("/sessions/{id}/alias", handler.UpdateSessionAlias)
|
||||
// 本任务的往返预算:在对话页里随时可改
|
||||
r.Get("/sessions/{id}/budget", handler.GetSessionBudgetHandler)
|
||||
r.Put("/sessions/{id}/budget", handler.UpdateSessionBudget)
|
||||
// Agent 在正文里提的改名建议:读取与驳回(接受走上面的 PUT alias)
|
||||
r.Get("/sessions/{id}/rename-proposal", handler.GetRenameProposal)
|
||||
r.Post("/sessions/{id}/rename-proposal/dismiss", handler.DismissRenameProposal)
|
||||
|
||||
// 联系人 name@path.session
|
||||
r.Get("/contacts", handler.ListContacts)
|
||||
r.Get("/contacts/suggest", handler.SuggestAddress)
|
||||
r.Post("/contacts/archive", handler.ArchiveContact)
|
||||
|
||||
// 权限决策
|
||||
r.Post("/permission/decide", handler.DecidePermission)
|
||||
r.Get("/permission/pending", handler.ListPendingPermissions)
|
||||
|
||||
// 在线 Agent 列表(补全用)
|
||||
r.Get("/agents", handler.ListAgents)
|
||||
|
||||
// 管理员
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AdminOnly)
|
||||
r.Get("/admin/users", handler.AdminListUsers)
|
||||
r.Post("/admin/users", handler.AdminCreateUser)
|
||||
r.Put("/admin/users/{id}", handler.AdminUpdateUser)
|
||||
r.Delete("/admin/users/{id}", handler.AdminDisableUser)
|
||||
r.Post("/admin/users/{id}/reset", handler.AdminResetPassword)
|
||||
r.Get("/admin/scopes", handler.AdminListScopes)
|
||||
|
||||
// Agent 接入密钥
|
||||
r.Post("/admin/agent-keys", handler.CreateAgentKey)
|
||||
r.Get("/admin/agent-keys", handler.ListAgentKeys)
|
||||
r.Delete("/admin/agent-keys/{id}", handler.DeleteAgentKey)
|
||||
r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey)
|
||||
|
||||
// Agent 发信配额
|
||||
r.Get("/admin/quotas", handler.AdminListQuotas)
|
||||
r.Put("/admin/quotas/{name}", handler.AdminSetQuota)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- SSE:Agent 走 header,人类走 Cookie,内部自行分流 ----
|
||||
r.Get("/events/stream", handler.SSEStream)
|
||||
r.Get("/events/status", handler.SSEStatus)
|
||||
|
||||
// ---- 附件下载:由浏览器直接发起(<a download>),无法带 Authorization 头,
|
||||
// 因此单独挂在允许 ?access_token= 的中间件下 ----
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.UserAuthAllowQueryToken)
|
||||
r.Get("/me/attachments/{id}", handler.MeDownloadAttachment)
|
||||
})
|
||||
})
|
||||
|
||||
// 静态前端
|
||||
staticRoot := os.Getenv("STATIC_DIR")
|
||||
var staticMux http.Handler
|
||||
if staticRoot != "" {
|
||||
staticMux = http.FileServer(http.Dir(staticRoot))
|
||||
} else {
|
||||
staticMux = static.Handler()
|
||||
}
|
||||
r.Handle("/assets/*", staticMux)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(static.GetIndex())
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 0, // SSE 长连接
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
fmt.Println("\nShutting down...")
|
||||
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(c)
|
||||
}()
|
||||
|
||||
fmt.Printf("AgentMail Gateway on %s\n", addr)
|
||||
fmt.Printf(" Health: http://localhost%s/health\n", addr)
|
||||
fmt.Printf(" API: http://localhost%s/api/v1\n", addr)
|
||||
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
fmt.Println("Server stopped")
|
||||
}
|
||||
|
||||
// bootstrapAdmin 首次启动时创建默认管理员
|
||||
func bootstrapAdmin(ctx context.Context, cfg *config.Config) {
|
||||
n, err := repo.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to count admins: %v", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pw := cfg.AdminPassword
|
||||
generated := false
|
||||
if pw == "" {
|
||||
pw = repo.RandomPassword(16)
|
||||
generated = true
|
||||
}
|
||||
|
||||
u, created, err := repo.EnsureAdminUser(ctx, cfg.AdminUser, pw)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
if created && u != nil {
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf(" 已创建默认管理员: %s\n", u.Username)
|
||||
if generated {
|
||||
fmt.Printf(" 初始密码(仅本次显示): %s\n", pw)
|
||||
fmt.Println(" 请登录后立即通过 /auth/password 修改")
|
||||
} else {
|
||||
fmt.Println(" 密码来自环境变量 ADMIN_PASSWORD")
|
||||
}
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
}
|
||||
|
||||
// sweepOrphanAttachments 周期清理「已上传但从未随邮件发出」的附件。
|
||||
//
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下孤立记录与文件。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程,也不至于让废弃文件长期占盘。
|
||||
func sweepOrphanAttachments(blobs *blob.Store) {
|
||||
const (
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
)
|
||||
|
||||
sweep := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sums, err := repo.SweepOrphanAttachments(ctx, keepFor)
|
||||
if err != nil {
|
||||
log.Printf("附件 GC 失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, sum := range sums {
|
||||
if err := blobs.Remove(sum); err != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], err)
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立文件", len(sums))
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时先扫一遍:上次进程可能是被 kill 掉的,留下的孤立文件不该等一小时
|
||||
sweep()
|
||||
for range time.Tick(interval) {
|
||||
sweep()
|
||||
}
|
||||
}
|
||||
28
gateway/go.mod
Normal file
28
gateway/go.mod
Normal file
@ -0,0 +1,28 @@
|
||||
module github.com/agentmail/gateway
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
80
gateway/go.sum
Normal file
80
gateway/go.sum
Normal file
@ -0,0 +1,80 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
149
gateway/internal/blob/store.go
Normal file
149
gateway/internal/blob/store.go
Normal file
@ -0,0 +1,149 @@
|
||||
// Package blob 提供附件文件的内容寻址存储。
|
||||
//
|
||||
// 设计取舍:文件内容存磁盘、数据库只存元数据。
|
||||
// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据,
|
||||
// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。
|
||||
//
|
||||
// 路径由内容的 sha256 派生(ab/cdef...),因此:
|
||||
// - 相同内容天然去重,重复上传不占额外空间
|
||||
// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越
|
||||
// - 两级目录前缀避免单目录塞进十万个文件
|
||||
package blob
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Store 是附件的磁盘存储。
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// ErrTooLarge 表示写入的数据超过了给定上限。
|
||||
var ErrTooLarge = errors.New("attachment too large")
|
||||
|
||||
var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// New 打开(必要时创建)一个位于 root 的附件库。
|
||||
func New(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("blob: root 不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("blob: 创建 %s: %w", root, err)
|
||||
}
|
||||
return &Store{root: root}, nil
|
||||
}
|
||||
|
||||
// Root 返回存储根目录(用于日志与运维排查)。
|
||||
func (s *Store) Root() string { return s.root }
|
||||
|
||||
// pathFor 由 sha256 推出磁盘路径。
|
||||
// 调用前必须确认 sum 是合法的 64 位十六进制,否则可能被拼出库外路径。
|
||||
func (s *Store) pathFor(sum string) (string, error) {
|
||||
if !sha256Re.MatchString(sum) {
|
||||
return "", fmt.Errorf("blob: 非法的 sha256 %q", sum)
|
||||
}
|
||||
return filepath.Join(s.root, sum[:2], sum[2:4], sum), nil
|
||||
}
|
||||
|
||||
// Put 把 r 的内容写入存储,返回内容的 sha256 与字节数。
|
||||
//
|
||||
// maxBytes > 0 时超限即中止并清理临时文件(不会留下半个文件)。
|
||||
// 先写临时文件再按内容哈希 rename:写入过程中崩溃不会产生一个「哈希对不上内容」的文件。
|
||||
func (s *Store) Put(r io.Reader, maxBytes int64) (string, int64, error) {
|
||||
tmp, err := os.CreateTemp(s.root, ".upload-*")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建临时文件: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// 失败路径统一清理;成功时 rename 之后这个 Remove 是无害的 no-op
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
src := r
|
||||
if maxBytes > 0 {
|
||||
// 多读 1 字节用于判断是否超限:LimitReader 到达上限时只会 EOF,
|
||||
// 无法区分「刚好等于上限」和「超过上限」。
|
||||
src = io.LimitReader(r, maxBytes+1)
|
||||
}
|
||||
|
||||
n, err := io.Copy(io.MultiWriter(tmp, h), src)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 写入: %w", err)
|
||||
}
|
||||
if maxBytes > 0 && n > maxBytes {
|
||||
return "", 0, ErrTooLarge
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: close: %w", err)
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
dst, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建目录: %w", err)
|
||||
}
|
||||
|
||||
// 已存在同内容文件:内容寻址下这就是同一个文件,直接复用
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return sum, n, nil
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: rename: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dst, 0o600); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: chmod: %w", err)
|
||||
}
|
||||
return sum, n, nil
|
||||
}
|
||||
|
||||
// Open 打开某个内容的读取句柄。调用方负责 Close。
|
||||
func (s *Store) Open(sum string) (*os.File, error) {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(p)
|
||||
}
|
||||
|
||||
// Exists 判断某内容是否已在库中。
|
||||
func (s *Store) Exists(sum string) bool {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Remove 删除某内容。
|
||||
//
|
||||
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
|
||||
// 因此调用方必须先确认没有其他记录引用该 sha256 才能删。
|
||||
func (s *Store) Remove(sum string) error {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
171
gateway/internal/blob/store_test.go
Normal file
171
gateway/internal/blob/store_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPutAndOpenRoundTrip(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("附件内容 with bytes \x00\x01")
|
||||
|
||||
sum, n, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Errorf("写入 %d 字节,报告 %d", len(data), n)
|
||||
}
|
||||
|
||||
h := sha256.Sum256(data)
|
||||
if sum != hex.EncodeToString(h[:]) {
|
||||
t.Errorf("sha256 = %s,与内容不符", sum)
|
||||
}
|
||||
|
||||
f, err := s.Open(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Error("读回的内容与写入不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// 相同内容重复上传必须复用同一个文件,不占额外空间。
|
||||
func TestPutDeduplicates(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("same content")
|
||||
|
||||
sum1, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum2, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum1 != sum2 {
|
||||
t.Fatalf("同内容得到不同哈希: %s vs %s", sum1, sum2)
|
||||
}
|
||||
|
||||
// 目录里应当只有一个内容文件(外加两级目录)
|
||||
var files int
|
||||
filepath.Walk(s.Root(), func(_ string, info os.FileInfo, _ error) error {
|
||||
if info != nil && !info.IsDir() {
|
||||
files++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if files != 1 {
|
||||
t.Errorf("去重后应只剩 1 个文件,实际 %d", files)
|
||||
}
|
||||
}
|
||||
|
||||
// 超限必须拒绝,且不能留下半个临时文件。
|
||||
func TestPutTooLargeLeavesNoGarbage(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("x"), 1024)
|
||||
|
||||
_, _, err := s.Put(bytes.NewReader(data), 512)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("期望 ErrTooLarge,得到 %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(s.Root())
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".upload-") {
|
||||
t.Errorf("超限后残留临时文件 %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恰好等于上限应当通过 —— 边界不能误杀。
|
||||
func TestPutExactlyAtLimit(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("y"), 512)
|
||||
|
||||
if _, n, err := s.Put(bytes.NewReader(data), 512); err != nil {
|
||||
t.Fatalf("恰好等于上限被拒: %v", err)
|
||||
} else if n != 512 {
|
||||
t.Errorf("字节数 = %d,want 512", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径完全由 sha256 派生,任何非法 sum 都不能落到库外。
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
|
||||
for _, bad := range []string{
|
||||
"../../etc/passwd",
|
||||
"..",
|
||||
"/etc/passwd",
|
||||
"ABCDEF", // 大写非法
|
||||
strings.Repeat("g", 64), // 非十六进制
|
||||
strings.Repeat("a", 63), // 长度不足
|
||||
"",
|
||||
} {
|
||||
if _, err := s.pathFor(bad); err == nil {
|
||||
t.Errorf("pathFor(%q) 应报错", bad)
|
||||
}
|
||||
if _, err := s.Open(bad); err == nil {
|
||||
t.Errorf("Open(%q) 应报错", bad)
|
||||
}
|
||||
if s.Exists(bad) {
|
||||
t.Errorf("Exists(%q) 应为 false", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成的路径必须落在库根目录之内。
|
||||
func TestPathStaysInsideRoot(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum := strings.Repeat("ab", 32)
|
||||
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel, err := filepath.Rel(s.Root(), p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
t.Errorf("路径逃出库根: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("z")), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Exists(sum) {
|
||||
t.Fatal("写入后应存在")
|
||||
}
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Exists(sum) {
|
||||
t.Error("删除后仍存在")
|
||||
}
|
||||
// 重复删除应当幂等,不报错
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Errorf("重复删除报错: %v", err)
|
||||
}
|
||||
}
|
||||
94
gateway/internal/config/config.go
Normal file
94
gateway/internal/config/config.go
Normal file
@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
CORSOrigins []string
|
||||
|
||||
// 首次启动时创建的默认管理员
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
|
||||
// Cookie 是否要求 HTTPS(生产环境置 true)
|
||||
SecureCookie bool
|
||||
CookieName string
|
||||
|
||||
// 附件:文件内容存盘,默认与 SQLite 同目录下的 attachments/
|
||||
AttachmentDir string
|
||||
// 单个附件上限(字节)。默认 25MB,与常见邮箱附件限额一致
|
||||
MaxAttachmentBytes int64
|
||||
}
|
||||
|
||||
var C *Config
|
||||
|
||||
func Load() *Config {
|
||||
C = &Config{
|
||||
Port: getEnv("PORT", "8180"),
|
||||
// 空值 = 用内置 SQLite(data/agentmail.db,可用 AGENTMAIL_DATA_DIR 改目录)。
|
||||
// 想接外部库就给 postgres://…;也接受 sqlite:///path/x.db 与裸路径。
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
CORSOrigins: splitEnv("CORS_ORIGINS", []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
}),
|
||||
AdminUser: strings.ToLower(getEnv("ADMIN_USER", "admin")),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
SecureCookie: getEnvBool("SECURE_COOKIE", false),
|
||||
CookieName: getEnv("COOKIE_NAME", "am_session"),
|
||||
|
||||
AttachmentDir: getEnv("AGENTMAIL_ATTACHMENT_DIR",
|
||||
filepath.Join(getEnv("AGENTMAIL_DATA_DIR", "data"), "attachments")),
|
||||
MaxAttachmentBytes: getEnvInt64("AGENTMAIL_MAX_ATTACHMENT_BYTES", 25<<20),
|
||||
}
|
||||
return C
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt64(key string, fallback int64) int64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitEnv(key string, fallback []string) []string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
193
gateway/internal/db/db.go
Normal file
193
gateway/internal/db/db.go
Normal file
@ -0,0 +1,193 @@
|
||||
// Package db 提供数据库连接与方言适配。
|
||||
//
|
||||
// AgentMail 默认用 SQLite(零依赖、单文件,配合 go:embed 的前端就是「一个二进制 + 一个 .db」),
|
||||
// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。
|
||||
//
|
||||
// 两种方言的差异集中在本包处理,repo 层只写一份 SQL:
|
||||
// - 占位符:SQLite 也支持 $1/$2,无需改写
|
||||
// - NOW() / gen_random_uuid():SQLite 侧注册同名函数补齐
|
||||
// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询)
|
||||
// - 唯一约束冲突:IsUniqueViolation 统一识别
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动:pgx
|
||||
sqlite "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Dialect string
|
||||
|
||||
const (
|
||||
Postgres Dialect = "postgres"
|
||||
SQLite Dialect = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
DB *sql.DB
|
||||
// D 是当前生效的方言,repo 层据此选择 SQL 片段
|
||||
D Dialect
|
||||
)
|
||||
|
||||
func init() {
|
||||
// SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。
|
||||
// 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。
|
||||
sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return uuid.NewString(), nil
|
||||
})
|
||||
|
||||
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻,
|
||||
// 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。
|
||||
sqlite.MustRegisterScalarFunction("now", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04:05"), nil
|
||||
})
|
||||
}
|
||||
|
||||
// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。
|
||||
func Connect(ctx context.Context, dsn string) error {
|
||||
driverName, connStr, dialect, err := resolve(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool, err := sql.Open(driverName, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
switch dialect {
|
||||
case Postgres:
|
||||
pool.SetMaxOpenConns(20)
|
||||
pool.SetMaxIdleConns(4)
|
||||
pool.SetConnMaxLifetime(30 * time.Minute)
|
||||
pool.SetConnMaxIdleTime(5 * time.Minute)
|
||||
case SQLite:
|
||||
// SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。
|
||||
pool.SetMaxOpenConns(1)
|
||||
pool.SetMaxIdleConns(1)
|
||||
pool.SetConnMaxLifetime(0)
|
||||
}
|
||||
|
||||
if err := pool.PingContext(ctx); err != nil {
|
||||
pool.Close()
|
||||
return fmt.Errorf("ping %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
DB = pool
|
||||
D = dialect
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。
|
||||
func resolve(dsn string) (string, string, Dialect, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
|
||||
if dsn == "" {
|
||||
return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
|
||||
return "pgx", dsn, Postgres, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite://"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite:"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "file:"):
|
||||
// 已是 SQLite URI,原样透传(调用方自带 pragma)
|
||||
return "sqlite", dsn, SQLite, nil
|
||||
|
||||
case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"):
|
||||
return "sqlite", sqliteDSN(dsn), SQLite, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q:期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn)
|
||||
}
|
||||
|
||||
// defaultDBPath 返回默认 SQLite 文件位置(AGENTMAIL_DATA_DIR 可覆盖)。
|
||||
func defaultDBPath() string {
|
||||
dir := os.Getenv("AGENTMAIL_DATA_DIR")
|
||||
if dir == "" {
|
||||
dir = "data"
|
||||
}
|
||||
return filepath.Join(dir, "agentmail.db")
|
||||
}
|
||||
|
||||
// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI,并确保父目录存在。
|
||||
//
|
||||
// - journal_mode=WAL:读写不互斥,SSE 长连接查询不会被写入阻塞
|
||||
// - busy_timeout=5000:并发写时排队 5s 而不是立刻 SQLITE_BUSY
|
||||
// - foreign_keys=1:SQLite 默认不校验外键,必须显式打开
|
||||
func sqliteDSN(path string) string {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
return "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=busy_timeout(5000)" +
|
||||
"&_pragma=foreign_keys(1)"
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 方言差异 ----------
|
||||
|
||||
// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段,argN 是该人名对应的占位符序号。
|
||||
//
|
||||
// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个
|
||||
// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。
|
||||
// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针;SQLite 侧用 json_each 展开逐项比对。
|
||||
func CCHas(col string, argN int) string {
|
||||
if D == Postgres {
|
||||
return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)",
|
||||
col, argN)
|
||||
}
|
||||
|
||||
// JSONCast 返回把占位符转成 JSONB 的后缀(PG 需要 ::jsonb,SQLite 存 TEXT 无需转换)。
|
||||
func JSONCast() string {
|
||||
if D == Postgres {
|
||||
return "::jsonb"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
var liteErr *sqlite.Error
|
||||
if errors.As(err, &liteErr) {
|
||||
// SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555
|
||||
code := liteErr.Code()
|
||||
return code == 2067 || code == 1555
|
||||
}
|
||||
return false
|
||||
}
|
||||
118
gateway/internal/db/migrate.go
Normal file
118
gateway/internal/db/migrate.go
Normal file
@ -0,0 +1,118 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/init.sql
|
||||
var initSQLPostgres string
|
||||
|
||||
//go:embed migrations/init_sqlite.sql
|
||||
var initSQLSQLite string
|
||||
|
||||
// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。
|
||||
func Migrate(ctx context.Context) error {
|
||||
switch D {
|
||||
case Postgres:
|
||||
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
|
||||
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
|
||||
return fmt.Errorf("migrate postgres: %w", err)
|
||||
}
|
||||
case SQLite:
|
||||
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
|
||||
for i, stmt := range splitStatements(initSQLSQLite) {
|
||||
if _, err := DB.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
|
||||
}
|
||||
}
|
||||
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
|
||||
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
|
||||
if err := addMissingColumns(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||||
func splitStatements(script string) []string {
|
||||
var out []string
|
||||
for _, raw := range strings.Split(script, ";") {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
|
||||
//
|
||||
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
|
||||
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法,
|
||||
// 只能先查 pragma 再决定加不加。
|
||||
//
|
||||
// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。
|
||||
var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
|
||||
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
|
||||
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
|
||||
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
|
||||
// 会话级往返预算(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"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
}
|
||||
|
||||
func addMissingColumns(ctx context.Context) error {
|
||||
for _, c := range sqliteAddColumns {
|
||||
has, err := columnExists(ctx, c.table, c.column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
fmt.Printf("补列 %s.%s\n", c.table, c.column)
|
||||
}
|
||||
for _, ddl := range sqliteAddIndexes {
|
||||
if _, err := DB.ExecContext(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||||
// pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。
|
||||
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
|
||||
var n int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
|
||||
table, column).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
297
gateway/internal/db/migrations/init.sql
Normal file
297
gateway/internal/db/migrations/init.sql
Normal file
@ -0,0 +1,297 @@
|
||||
-- AgentMail MVP Schema
|
||||
-- PostgreSQL 14+
|
||||
|
||||
-- Users table(人类多用户;username 与 agents.agent_name 共用命名空间)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
user_agent VARCHAR(256) DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
|
||||
-- allowed_agents:["deepseekharness","pi"],空数组 = 不限(继承系统默认)
|
||||
-- allowed_paths :["/program","/home/x"],空数组 = 不限;按前缀匹配
|
||||
-- agent_aliases :{"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Agents table
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_name VARCHAR(64) NOT NULL UNIQUE,
|
||||
secret VARCHAR(128) NOT NULL,
|
||||
host_url VARCHAR(256) NOT NULL DEFAULT '',
|
||||
workspaces JSONB NOT NULL DEFAULT '[]',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||
max_rounds INT NOT NULL DEFAULT 10,
|
||||
used_rounds INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128),
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
owner_user_id UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
-- 已存在的库补列(必须先于依赖该列的索引)
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- Mails table
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id UUID REFERENCES mails(mail_id),
|
||||
|
||||
from_name VARCHAR(64) NOT NULL,
|
||||
from_workspace VARCHAR(128) DEFAULT '',
|
||||
to_name VARCHAR(64) NOT NULL,
|
||||
to_workspace VARCHAR(128) DEFAULT '',
|
||||
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
permission_options JSONB,
|
||||
permission_result VARCHAR(32),
|
||||
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unread',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
hop_limit INT DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
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 '[]';
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
|
||||
|
||||
-- 抄送检索:cc_list @> '[{"name":"pi"}]' 走 GIN
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
|
||||
|
||||
-- 历史数据规整:早期写入过首字母大写的键(Raw/Name/Path/Session),统一成小写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'name', COALESCE(e->>'name', e->>'Name'),
|
||||
'path', COALESCE(e->>'path', e->>'Path'),
|
||||
'session', COALESCE(e->>'session', e->>'Session'),
|
||||
'raw', COALESCE(e->>'raw', e->>'Raw')
|
||||
))
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @? '$[*].Name';
|
||||
|
||||
-- Permission requests table
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(mail_id),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
result VARCHAR(32),
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
|
||||
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
|
||||
DO $$
|
||||
DECLARE
|
||||
admin_name TEXT;
|
||||
BEGIN
|
||||
SELECT username INTO admin_name
|
||||
FROM users WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1;
|
||||
|
||||
IF admin_name IS NULL THEN
|
||||
RETURN; -- 还没有管理员,下次迁移再试
|
||||
END IF;
|
||||
|
||||
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
|
||||
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
|
||||
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
|
||||
|
||||
-- 拄送列表里的 human 一并重写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
CASE WHEN e->>'name' = 'human'
|
||||
THEN jsonb_set(
|
||||
jsonb_set(e, '{name}', to_jsonb(admin_name)),
|
||||
'{raw}',
|
||||
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
|
||||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
|
||||
ELSE '.' || (e->>'session') END))
|
||||
ELSE e END
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @> '[{"name":"human"}]';
|
||||
|
||||
-- 人类发起的会话补上 owner
|
||||
UPDATE sessions s
|
||||
SET owner_user_id = u.user_id
|
||||
FROM users u
|
||||
WHERE u.username = admin_name
|
||||
AND s.owner_user_id IS NULL
|
||||
AND s.from_agent = admin_name;
|
||||
END $$;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
agent_name VARCHAR(64), -- NULL = 待绑定
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
relay_key VARCHAR(160) NOT NULL,
|
||||
mail_id UUID REFERENCES mails(mail_id),
|
||||
kind VARCHAR(32) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
uploader VARCHAR(64) NOT NULL,
|
||||
filename VARCHAR(512) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
241
gateway/internal/db/migrations/init_sqlite.sql
Normal file
241
gateway/internal/db/migrations/init_sqlite.sql
Normal file
@ -0,0 +1,241 @@
|
||||
-- AgentMail Schema — SQLite(默认后端)
|
||||
--
|
||||
-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言:
|
||||
-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成)
|
||||
-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time)
|
||||
-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索)
|
||||
-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责)
|
||||
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
|
||||
--
|
||||
-- 本文件只建表建索引,不含数据迁移:SQLite 是新引入的默认后端,不存在历史库。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
|
||||
-- 权限边界:空数组 = 不限
|
||||
allowed_agents TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_paths TEXT NOT NULL DEFAULT '[]',
|
||||
agent_aliases TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
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,
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_agent TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
agent_name TEXT NOT NULL UNIQUE,
|
||||
secret TEXT NOT NULL,
|
||||
host_url TEXT NOT NULL DEFAULT '',
|
||||
workspaces TEXT NOT NULL DEFAULT '[]',
|
||||
platform TEXT NOT NULL DEFAULT 'pi',
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
max_rounds INTEGER NOT NULL DEFAULT 10,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_alias TEXT,
|
||||
from_agent TEXT NOT NULL,
|
||||
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,
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
rename_dismissed TEXT,
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉,
|
||||
-- 人上一秒记住的寻址地址下一秒失效。
|
||||
alias_source TEXT NOT NULL DEFAULT 'platform',
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id TEXT REFERENCES mails(mail_id),
|
||||
|
||||
from_name TEXT NOT NULL,
|
||||
from_workspace TEXT DEFAULT '',
|
||||
to_name TEXT NOT NULL,
|
||||
to_workspace TEXT DEFAULT '',
|
||||
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type TEXT NOT NULL DEFAULT 'normal',
|
||||
permission_options TEXT,
|
||||
permission_result TEXT,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'unread',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
hop_limit INTEGER DEFAULT 5,
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
-- 「谁在哪一封里提了什么」应当留痕。
|
||||
rename_alias TEXT,
|
||||
rename_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at);
|
||||
|
||||
-- 抄送检索无对应索引:SQLite 侧走 json_each 展开。
|
||||
-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL DEFAULT '["同意","拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
result TEXT,
|
||||
decided_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
agent_name TEXT, -- NULL = 待绑定
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_by TEXT REFERENCES users(user_id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
-- 不塞 BLOB:SQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL,而附件是只写一次多次读的冷数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name TEXT NOT NULL,
|
||||
relay_key TEXT NOT NULL,
|
||||
mail_id TEXT REFERENCES mails(mail_id),
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
|
||||
-- 上传者(Agent 名或用户名),用于「只能挂自己上传的附件」校验
|
||||
uploader TEXT NOT NULL,
|
||||
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
|
||||
sha256 TEXT NOT NULL,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
-- GC 扫描待挂载附件用
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
142
gateway/internal/handler/agents.go
Normal file
142
gateway/internal/handler/agents.go
Normal file
@ -0,0 +1,142 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// ---------- Agent ----------
|
||||
|
||||
type registerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
//
|
||||
// 两种认证方式:
|
||||
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
|
||||
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
|
||||
// 否则等于拿别人的密钥冒充新身份。
|
||||
// 2. body 里带 secret —— 旧方式,兼容保留。
|
||||
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing name")
|
||||
return
|
||||
}
|
||||
|
||||
keyToken := middleware.BearerToken(r)
|
||||
if keyToken == "" && req.Secret == "" {
|
||||
Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret")
|
||||
return
|
||||
}
|
||||
|
||||
if keyToken != "" {
|
||||
bound, err := repo.VerifyAgentKey(r.Context(), keyToken)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
if bound != "" && bound != req.Name {
|
||||
Error(w, http.StatusForbidden,
|
||||
"该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Platform == "" {
|
||||
req.Platform = "pi"
|
||||
}
|
||||
|
||||
// 三维地址的 name 位与人类用户名共用命名空间,不得重名
|
||||
if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to validate agent name")
|
||||
return
|
||||
} else if !ok {
|
||||
Error(w, http.StatusConflict, "该名称已被人类用户占用")
|
||||
return
|
||||
}
|
||||
if req.Name == "human" {
|
||||
Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名")
|
||||
return
|
||||
}
|
||||
|
||||
// 密钥认证时不需要 secret,但 agents.secret 非空约束仍在;
|
||||
// 存密钥本身作占位,旧的 name/secret 路径不受影响。
|
||||
secret := req.Secret
|
||||
if secret == "" {
|
||||
secret = keyToken
|
||||
}
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to register agent")
|
||||
return
|
||||
}
|
||||
|
||||
// 待绑定密钥在首次注册成功后落定到该 Agent
|
||||
if keyToken != "" {
|
||||
if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to bind key")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "registered",
|
||||
"agent_name": req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/heartbeat
|
||||
func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := repo.HeartbeatAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to heartbeat")
|
||||
return
|
||||
}
|
||||
|
||||
// 心跳回传配额:插件据此把剩余次数注入 Agent 上下文,
|
||||
// 让它在配额耗尽前主动发总结,而不是撞到 403 才发现。
|
||||
quota, qErr := repo.GetQuota(r.Context(), agentName)
|
||||
if qErr != nil {
|
||||
// 配额读不到不影响心跳本身,降级为不限额
|
||||
quota = repo.Quota{AgentName: agentName, Unlimited: true, Remaining: -1}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"pending_mails": pending,
|
||||
"quota": quota,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
func ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
|
||||
agents, err := repo.ListAgents(r.Context(), statusFilter)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(agents),
|
||||
})
|
||||
}
|
||||
69
gateway/internal/handler/alias_test.go
Normal file
69
gateway/internal/handler/alias_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// 平台侧 slug/标题不受本侧寻址约束,normalizeAlias 必须把它改写成
|
||||
// 能安全出现在 name@path.<alias> 末段的形式。
|
||||
func TestNormalizeAlias(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// opencode 风格 slug 原样通过
|
||||
{"jolly-cactus", "jolly-cactus"},
|
||||
{"fix-memory-leak", "fix-memory-leak"},
|
||||
|
||||
// 非法字符统一换 -,连续的压缩成一个
|
||||
{"fix.memory.leak", "fix-memory-leak"},
|
||||
{"修复 登录态 丢失", "修复-登录态-丢失"},
|
||||
{"a//b..c", "a-b-c"},
|
||||
{"user@host", "user-host"},
|
||||
|
||||
// 首尾的分隔符要去掉
|
||||
{".leading", "leading"},
|
||||
{"trailing.", "trailing"},
|
||||
{" spaced ", "spaced"},
|
||||
|
||||
// 保留字必须避开,否则会被寻址当成「新建会话」
|
||||
{"new", "session-new"},
|
||||
|
||||
// 全是非法字符 → 空串,交由调用方报错
|
||||
{"...", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := normalizeAlias(c.in); got != c.want {
|
||||
t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化后的别名必须能通过寻址校验,否则同步会写进一个自己都拒绝的别名。
|
||||
func TestNormalizeAliasPassesValidation(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"jolly-cactus", "fix.memory.leak", "修复 登录态 丢失", "new", "user@host/path",
|
||||
} {
|
||||
norm := normalizeAlias(in)
|
||||
if norm == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSessionAlias(norm); err != nil {
|
||||
t.Errorf("normalizeAlias(%q) = %q,但未通过 validateSessionAlias: %v", in, norm, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 截断长别名时不能切坏多字节字符(session_alias 是 VARCHAR(128))。
|
||||
func TestNormalizeAliasTruncatesOnValidUTF8(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "修" // 每个 3 字节,共 300 字节
|
||||
}
|
||||
got := normalizeAlias(long)
|
||||
if len(got) > 128 {
|
||||
t.Errorf("normalizeAlias 截断后 %d 字节,超过 128", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r == '\uFFFD' {
|
||||
t.Fatalf("normalizeAlias 截断产生了非法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
368
gateway/internal/handler/attachments.go
Normal file
368
gateway/internal/handler/attachments.go
Normal file
@ -0,0 +1,368 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/blob"
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 上传与发信是两步:
|
||||
// 1. POST /attachments (multipart)→ 拿到 attachment_id
|
||||
// 2. 发信时把 id 放进 attachment_ids
|
||||
// 之所以不合成一步:Agent 侧的工具接口是 JSON,没法带 multipart;
|
||||
// 而人类侧若只支持一步,就无法在写信过程中先传文件再改正文。
|
||||
//
|
||||
// 未挂载的附件是合法中间态,超时由 GC 清理(repo.SweepOrphanAttachments)。
|
||||
|
||||
// Blobs 是附件内容存储,由 main 在启动时注入。
|
||||
var Blobs *blob.Store
|
||||
|
||||
// sanitizeFilename 清理用户提供的文件名。
|
||||
//
|
||||
// 文件名只用于展示与下载时的 Content-Disposition,磁盘路径完全由 sha256 派生,
|
||||
// 因此这里的目的不是防路径穿越(那已由内容寻址杜绝),而是:
|
||||
// - 去掉目录成分,避免下载时浏览器按 "a/b/c.txt" 解释
|
||||
// - 去掉控制字符与换行,避免污染 HTTP 响应头
|
||||
// - 限长,避免超出数据库列宽
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
// 同时处理 / 与 \:上传方可能是 Windows 客户端
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
continue // 控制字符一律丢弃
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
name = strings.TrimSpace(b.String())
|
||||
|
||||
// "." 与 ".." 作为文件名毫无意义,且容易在各层被特殊解释
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "unnamed"
|
||||
}
|
||||
|
||||
const maxBytes = 255
|
||||
if len(name) > maxBytes {
|
||||
cut := name[:maxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
name = cut
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// detectContentType 优先用客户端声明的类型,缺失时按扩展名猜,兜底 octet-stream。
|
||||
// 无论如何都不回显未经处理的客户端值到响应头(下载时统一用 octet-stream,见 DownloadAttachment)。
|
||||
func detectContentType(declared, filename string) string {
|
||||
if ct := strings.TrimSpace(declared); ct != "" && ct != "application/octet-stream" {
|
||||
if parsed, _, err := mime.ParseMediaType(ct); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
if ext := filepath.Ext(filename); ext != "" {
|
||||
if byExt := mime.TypeByExtension(ext); byExt != "" {
|
||||
if parsed, _, err := mime.ParseMediaType(byExt); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// uploadAttachment 是 Agent 与人类两条上传路径的公共实现。
|
||||
func uploadAttachment(w http.ResponseWriter, r *http.Request, uploader string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
max := config.C.MaxAttachmentBytes
|
||||
|
||||
// 双层限制:MaxBytesReader 卡整个请求体(含 multipart 边界与其他字段),
|
||||
// blob.Put 的 max 卡单个文件内容。少了外层,攻击者可以用超大 multipart 头拖死内存。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
|
||||
|
||||
// 32MB 内存缓冲上限,超出部分 multipart 会自动落临时文件
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "缺少 file 字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
name := sanitizeFilename(header.Filename)
|
||||
ctype := detectContentType(header.Header.Get("Content-Type"), name)
|
||||
|
||||
// 先落盘再入库:反过来会出现「库里有记录、磁盘没文件」的下载 500
|
||||
sum, size, err := Blobs.Put(file, max)
|
||||
if errors.Is(err, blob.ErrTooLarge) {
|
||||
Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "保存附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.CreateAttachment(r.Context(), uploader, name, ctype, size, sum)
|
||||
if err != nil {
|
||||
// 落盘成功但入库失败:留下的孤立文件由 GC 回收,不影响正确性
|
||||
Error(w, http.StatusInternalServerError, "登记附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{"attachment": a})
|
||||
}
|
||||
|
||||
// POST /api/v1/attachments —— Agent 侧上传
|
||||
func UploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/attachments —— 人类侧上传
|
||||
func MeUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// downloadAttachment 是 Agent 与人类两条下载路径的公共实现。
|
||||
func downloadAttachment(w http.ResponseWriter, r *http.Request, viewer string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.AttachmentAccessible(r.Context(), a, viewer)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "校验权限失败")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该附件")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := Blobs.Open(a.SHA256)
|
||||
if err != nil {
|
||||
// 元数据在库但文件不在盘:说明存储被外部改动过,这是运维问题而非用户输入问题
|
||||
Error(w, http.StatusInternalServerError, "附件内容缺失")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 一律 octet-stream + attachment:绝不按声明的 MIME 内联渲染。
|
||||
// 否则一个上传的 .html/.svg 就能在本站域下执行脚本,等于自带 XSS。
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", a.SizeBytes))
|
||||
w.Header().Set("Content-Disposition", contentDisposition(a.Filename))
|
||||
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// contentDisposition 构造下载头。
|
||||
// filename* 用 RFC 5987 编码承载非 ASCII 名字,filename= 给只认 ASCII 的老客户端兜底;
|
||||
// 兜底值里的引号与反斜杠必须去掉,否则能截断响应头。
|
||||
func contentDisposition(name string) string {
|
||||
var ascii strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r == '"' || r == '\\':
|
||||
ascii.WriteByte('_')
|
||||
case r < 0x20 || r > 0x7e:
|
||||
ascii.WriteByte('_')
|
||||
default:
|
||||
ascii.WriteRune(r)
|
||||
}
|
||||
}
|
||||
fallback := ascii.String()
|
||||
if fallback == "" {
|
||||
fallback = "attachment"
|
||||
}
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
fallback, urlEncodeRFC5987(name))
|
||||
}
|
||||
|
||||
// urlEncodeRFC5987 按 RFC 5987 的 attr-char 集合做百分号编码。
|
||||
func urlEncodeRFC5987(s string) string {
|
||||
const safe = "!#$&+-.^_`|~" // attr-char 中除字母数字外允许的字符
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
if isAlnum || strings.IndexByte(safe, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GET /api/v1/attachments/{id} —— Agent 侧下载
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/attachments/{id} —— 人类侧下载
|
||||
func MeDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/attachments/{id} —— 删除自己上传且尚未挂载的附件
|
||||
//
|
||||
// 已挂载的不允许删:邮件是不可篡改的历史记录,附件是它的一部分。
|
||||
func MeDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
if a.Uploader != user.Username {
|
||||
Error(w, http.StatusForbidden, "只能删除自己上传的附件")
|
||||
return
|
||||
}
|
||||
if a.MailID != nil {
|
||||
Error(w, http.StatusConflict, "附件已随邮件发出,不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
sum, orphaned, err := repo.DeleteAttachment(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "删除附件失败")
|
||||
return
|
||||
}
|
||||
// 内容寻址下多条记录可能共享同一文件,只有最后一条引用消失才删磁盘
|
||||
if orphaned && Blobs != nil {
|
||||
_ = Blobs.Remove(sum)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// parseAttachmentIDs 把请求里的附件 id 列表解析为 UUID。
|
||||
func parseAttachmentIDs(raw []string) ([]uuid.UUID, error) {
|
||||
out := make([]uuid.UUID, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("非法的 attachment_id %q", s)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachAll 把附件挂到刚创建的邮件上,并把错误翻译成 HTTP 响应。
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
err := repo.AttachToMail(r.Context(), mailID, ids, uploader)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
case errors.Is(err, repo.ErrAttachmentNotFound):
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
case errors.Is(err, repo.ErrAttachmentNotOwned):
|
||||
Error(w, http.StatusForbidden, "只能附加自己上传的附件")
|
||||
case errors.Is(err, repo.ErrAttachmentAlreadyAttached):
|
||||
Error(w, http.StatusConflict, "附件已随其他邮件发出")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "附加附件失败")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。
|
||||
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
|
||||
func fillAttachments(r *http.Request, mails ...*models.Mail) {
|
||||
for _, m := range mails {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if as, err := repo.ListAttachmentsFor(r.Context(), m.ID); err == nil {
|
||||
m.Attachments = as
|
||||
}
|
||||
}
|
||||
}
|
||||
138
gateway/internal/handler/attachments_test.go
Normal file
138
gateway/internal/handler/attachments_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 文件名只用于展示与下载头;磁盘路径由 sha256 派生,
|
||||
// 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"report.pdf", "report.pdf"},
|
||||
{"中文 文件名.txt", "中文 文件名.txt"},
|
||||
|
||||
// 目录成分必须剥掉(含 Windows 风格)
|
||||
{"../../etc/passwd", "passwd"},
|
||||
{"/abs/path/x.log", "x.log"},
|
||||
{`C:\Users\me\a.txt`, "a.txt"},
|
||||
{"a/b/c.txt", "c.txt"},
|
||||
|
||||
// 控制字符会污染 Content-Disposition
|
||||
{"bad\r\nname.txt", "badname.txt"},
|
||||
{"tab\there.txt", "tabhere.txt"},
|
||||
|
||||
// 无意义的名字兜底
|
||||
{"", "unnamed"},
|
||||
{" ", "unnamed"},
|
||||
{".", "unnamed"},
|
||||
{"..", "unnamed"},
|
||||
{"/", "unnamed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeFilename(c.in); got != c.want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超长名字按 UTF-8 边界截断,不产生非法序列。
|
||||
func TestSanitizeFilenameTruncates(t *testing.T) {
|
||||
long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+
|
||||
got := sanitizeFilename(long)
|
||||
if len(got) > 255 {
|
||||
t.Errorf("截断后 %d 字节,超过 255", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r == '\uFFFD' {
|
||||
t.Fatalf("截断产生非法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectContentType(t *testing.T) {
|
||||
cases := []struct{ declared, filename, want string }{
|
||||
{"application/pdf", "x.pdf", "application/pdf"},
|
||||
// 客户端没给类型时按扩展名猜
|
||||
{"", "notes.txt", "text/plain"},
|
||||
{"application/octet-stream", "data.json", "application/json"},
|
||||
// 带参数的声明要剥掉参数
|
||||
{"text/plain; charset=utf-8", "a.txt", "text/plain"},
|
||||
// 认不出就兜底
|
||||
{"", "blob.unknownext", "application/octet-stream"},
|
||||
{"garbage//not-a-type", "blob.unknownext", "application/octet-stream"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := detectContentType(c.declared, c.filename)
|
||||
// mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型
|
||||
if !strings.HasPrefix(got, c.want) {
|
||||
t.Errorf("detectContentType(%q, %q) = %q, want prefix %q",
|
||||
c.declared, c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Disposition 必须双写:filename* 承载 UTF-8,filename= 给老客户端兜底。
|
||||
// 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。
|
||||
func TestContentDisposition(t *testing.T) {
|
||||
got := contentDisposition("报告 v2.pdf")
|
||||
if !strings.HasPrefix(got, "attachment; ") {
|
||||
t.Errorf("必须以 attachment 开头: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "filename*=UTF-8''") {
|
||||
t.Errorf("缺少 RFC 5987 编码: %s", got)
|
||||
}
|
||||
// 非 ASCII 不能出现在 filename= 的兜底值里
|
||||
ascii := got[:strings.Index(got, "filename*=")]
|
||||
for _, r := range ascii {
|
||||
if r > 0x7e {
|
||||
t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii)
|
||||
}
|
||||
}
|
||||
|
||||
// 引号注入不能逃出引号
|
||||
evil := contentDisposition(`a"; x="y`)
|
||||
if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) {
|
||||
t.Errorf("引号未转义,可截断响应头: %s", evil)
|
||||
}
|
||||
|
||||
// 控制字符(若绕过 sanitize 直达此处)也不能出现
|
||||
ctl := contentDisposition("a\r\nb.txt")
|
||||
if strings.ContainsAny(ctl, "\r\n") {
|
||||
t.Errorf("响应头含换行: %q", ctl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLEncodeRFC5987(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"abc.txt", "abc.txt"},
|
||||
{"a b", "a%20b"},
|
||||
{"中", "%E4%B8%AD"},
|
||||
{`a"b`, "a%22b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := urlEncodeRFC5987(c.in); got != c.want {
|
||||
t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttachmentIDs(t *testing.T) {
|
||||
valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
|
||||
|
||||
got, err := parseAttachmentIDs([]string{valid, " ", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("合法输入报错: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("空白项应被忽略,得到 %d 个", len(got))
|
||||
}
|
||||
|
||||
if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil {
|
||||
t.Error("非法 UUID 应报错")
|
||||
}
|
||||
|
||||
if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 {
|
||||
t.Errorf("nil 应返回空列表,得到 %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
414
gateway/internal/handler/auth.go
Normal file
414
gateway/internal/handler/auth.go
Normal file
@ -0,0 +1,414 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 登录 / 登出 / 自身信息 ----------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userOut struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
LastLogin string `json:"last_login,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func toUserOut(u *models.User) userOut {
|
||||
o := userOut{
|
||||
UserID: u.ID.String(),
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
AllowedAgents: emptySlice(u.AllowedAgents),
|
||||
AllowedPaths: emptySlice(u.AllowedPaths),
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if u.LastLogin != nil {
|
||||
o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导
|
||||
func SetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := repo.NeedsSetup(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check setup status")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用
|
||||
func SetupAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var req setupRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码自少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrAlreadySetup):
|
||||
Error(w, http.StatusConflict, "系统已初始化,请直接登录")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被 Agent 占用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "初始化失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化后直接登录
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err == nil {
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
if name == "" || req.Password == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing username or password")
|
||||
return
|
||||
}
|
||||
|
||||
if locked, remain := limiter.Locked(name); locked {
|
||||
JSON(w, http.StatusTooManyRequests, map[string]interface{}{
|
||||
"error": "尝试过于频繁,请稍后再试",
|
||||
"retry_after": remain,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.Authenticate(r.Context(), name, req.Password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrBadCredentials):
|
||||
limiter.Fail(name)
|
||||
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
case errors.Is(err, repo.ErrUserDisabled):
|
||||
Error(w, http.StatusForbidden, "账号已被禁用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "登录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
limiter.Reset(name)
|
||||
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "无法创建会话")
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": toUserOut(u),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if token := middleware.SessionToken(r); token != "" {
|
||||
_ = repo.DeleteUserSession(r.Context(), token)
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/password
|
||||
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "新密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil {
|
||||
Error(w, http.StatusUnauthorized, "原密码错误")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "修改密码失败")
|
||||
return
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := repo.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list users")
|
||||
return
|
||||
}
|
||||
out := make([]userOut, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, toUserOut(&users[i]))
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"users": out})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role,
|
||||
req.AllowedAgents, req.AllowedPaths)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "创建用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
AllowedAgents *[]string `json:"allowed_agents"`
|
||||
AllowedPaths *[]string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/{id}
|
||||
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// 不允许把最后一个管理员降级或禁用
|
||||
if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{
|
||||
DisplayName: req.DisplayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
AllowedAgents: req.AllowedAgents,
|
||||
AllowedPaths: req.AllowedPaths,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "更新用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选
|
||||
func AdminListScopes(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
paths, _ := repo.AllWorkspaceNames(r.Context())
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(names),
|
||||
"paths": emptySlice(paths),
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史
|
||||
func AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled := "disabled"
|
||||
if err := guardLastAdmin(r, id, nil, &disabled); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err := repo.DisableUser(r.Context(), id); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "禁用用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/{id}/reset
|
||||
func AdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req resetPasswordRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "重置密码失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
||||
}
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, key))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid "+key)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用
|
||||
func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error {
|
||||
demoting := role != nil && *role != "admin"
|
||||
disabling := status != nil && *status != "active"
|
||||
if !demoting && !disabling {
|
||||
return nil
|
||||
}
|
||||
|
||||
target, err := repo.GetUserByID(r.Context(), id)
|
||||
if err != nil || !target.IsAdmin() || target.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
n, err := repo.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n <= 1 {
|
||||
return errors.New("系统至少需要保留一个可用管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
207
gateway/internal/handler/contacts.go
Normal file
207
gateway/internal/handler/contacts.go
Normal file
@ -0,0 +1,207 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- Contacts(左侧联系人界面,按登录用户隔离) ----------
|
||||
|
||||
// GET /api/v1/contacts?archived=false
|
||||
func ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
|
||||
// 管理员可用 ?all=true 查看全部
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
contacts, err := repo.ListContactsFor(r.Context(), scope, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"contacts": emptySlice(contacts),
|
||||
})
|
||||
}
|
||||
|
||||
type archiveRequest struct {
|
||||
Address string `json:"address"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// POST /api/v1/contacts/archive
|
||||
// 归档指定 name@path.session:Agent 侧会话归档 + 邮箱界面移除
|
||||
func ArchiveContact(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req archiveRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var sessionID uuid.UUID
|
||||
switch {
|
||||
case req.SessionID != "":
|
||||
id, err := uuid.Parse(req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
case req.Address != "":
|
||||
addr, err := models.ParseAddress(req.Address)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid address: "+err.Error())
|
||||
return
|
||||
}
|
||||
id, err := repo.FindSessionByAddress(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "No session matches "+req.Address)
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "Provide address or session_id")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:只能归档自己参与的会话(管理员不限)
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权归档他人的会话")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, _ := repo.GetSessionMails(r.Context(), sessionID)
|
||||
|
||||
if err := repo.ArchiveSession(r.Context(), sessionID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to archive session")
|
||||
return
|
||||
}
|
||||
|
||||
alias := ""
|
||||
if session.Alias != nil {
|
||||
alias = *session.Alias
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
"archived_by": user.Username,
|
||||
}
|
||||
|
||||
// 通知会话内所有参与方(Agent 与人类),各自归档/移除
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mails {
|
||||
names := append([]string{m.FromName, m.ToName}, ccNames(m.CCList)...)
|
||||
for _, name := range names {
|
||||
if name == "" || notified[name] {
|
||||
continue
|
||||
}
|
||||
notified[name] = true
|
||||
sse.Default.SendToRecipient(name, "session_archived", payload)
|
||||
}
|
||||
}
|
||||
if !notified[user.Username] {
|
||||
sse.Default.SendToUser(user.Username, "session_archived", payload)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "archived",
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/contacts/suggest?name=xxx&path=yyy
|
||||
// 三段式补全:无 name 给 Agent+人类用户名;有 name 给工作区;两者都有给会话别名(含 new)
|
||||
func SuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
if name == "" {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
users, _ := repo.ListActiveUsernames(r.Context())
|
||||
|
||||
names := make([]string, 0, len(agents)+len(users))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == user.Username {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, u)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
// 人类用户没有工作区,直接给空列表(前端会继续走 session 段)
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path)
|
||||
sessions = append(sessions, "new")
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "session",
|
||||
"suggestions": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
func ccNames(list []models.Address) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.Name != "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
91
gateway/internal/handler/events.go
Normal file
91
gateway/internal/handler/events.go
Normal file
@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
)
|
||||
|
||||
// GET /api/v1/events/stream
|
||||
//
|
||||
// 四种凭证,都必须真正验证过身份才能订阅:
|
||||
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
|
||||
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
|
||||
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
|
||||
// ?access_token=<token> → 浏览器 EventSource 专用回退
|
||||
//
|
||||
// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。
|
||||
// query 令牌仅本端点接受(EventSource 无法带自定义头),其余接口一律要求请求头,
|
||||
// 因为 URL 里的令牌会进访问日志与 Referer。
|
||||
func SSEStream(w http.ResponseWriter, r *http.Request) {
|
||||
agentName, ok := resolveStreamAgent(r)
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "凭证无效")
|
||||
return
|
||||
}
|
||||
|
||||
userName := ""
|
||||
if agentName == "" {
|
||||
u := middleware.OptionalUserWithQuery(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
userName = u.Username
|
||||
}
|
||||
|
||||
client := sse.Default.AddClient(w, agentName, userName)
|
||||
if client == nil {
|
||||
Error(w, http.StatusInternalServerError, "SSE not supported")
|
||||
return
|
||||
}
|
||||
|
||||
<-r.Context().Done()
|
||||
sse.Default.RemoveClient(client.ID)
|
||||
}
|
||||
|
||||
// resolveStreamAgent 校验 Agent 侧凭证。
|
||||
// 返回 ("", true) 表示这不是 Agent 请求,交给人类用户分支;
|
||||
// 返回 ("", false) 表示带了 Agent 凭证但验证失败。
|
||||
func resolveStreamAgent(r *http.Request) (string, bool) {
|
||||
// 密钥认证:Bearer 令牌可能是 Agent 密钥,也可能是用户密钥。
|
||||
// 先按 Agent 密钥试,失败就落到人类分支(那里会再按用户密钥试)。
|
||||
token := middleware.BearerToken(r)
|
||||
if token == "" {
|
||||
token = middleware.QueryToken(r) // EventSource 回退
|
||||
}
|
||||
if token != "" {
|
||||
name, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err == nil && name != "" {
|
||||
return name, true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
name := r.Header.Get("X-Agent-Name")
|
||||
if name == "" {
|
||||
name = r.URL.Query().Get("agent_name")
|
||||
}
|
||||
if name == "" {
|
||||
return "", true // 非 Agent 请求
|
||||
}
|
||||
|
||||
secret := r.Header.Get("X-Agent-Secret")
|
||||
if secret == "" {
|
||||
return "", false // 报了名字却没给凭证
|
||||
}
|
||||
agent, err := repo.VerifyAgent(r.Context(), name, secret)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return agent.Name, true
|
||||
}
|
||||
|
||||
// GET /api/v1/events/status
|
||||
func SSEStatus(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"connected_clients": sse.Default.ClientCount(),
|
||||
})
|
||||
}
|
||||
264
gateway/internal/handler/forward.go
Normal file
264
gateway/internal/handler/forward.go
Normal file
@ -0,0 +1,264 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 转发 ----------
|
||||
//
|
||||
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
|
||||
// 回复(reply_to)落回原会话,收件人是原发件人;
|
||||
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
|
||||
// 因此转发不复用 reply_to,而是走完整的三维寻址。
|
||||
|
||||
type forwardRequest struct {
|
||||
// To 新收件人,完整三维地址
|
||||
To string `json:"to"`
|
||||
// CC 可选抄送
|
||||
CC string `json:"cc"`
|
||||
// Comment 转发者附加的说明,置于引用原文之前
|
||||
Comment string `json:"comment"`
|
||||
// Subject 可选;留空时自动加 "Fwd: " 前缀
|
||||
Subject string `json:"subject"`
|
||||
// SessionAlias 仅在目标地址以 .new 结尾时生效
|
||||
SessionAlias string `json:"session_alias"`
|
||||
}
|
||||
|
||||
// quoteBody 把原文渲染为 Markdown 引用块。
|
||||
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func quoteBody(m *models.Mail) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
|
||||
if m.FromWorkspace != "" {
|
||||
b.WriteString("@" + m.FromWorkspace)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
|
||||
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
|
||||
if len(m.CCList) > 0 {
|
||||
names := make([]string, 0, len(m.CCList))
|
||||
for _, c := range m.CCList {
|
||||
names = append(names, c.Raw)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
|
||||
}
|
||||
b.WriteString(">\n")
|
||||
for _, line := range strings.Split(m.Body, "\n") {
|
||||
b.WriteString("> " + line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
|
||||
func forwardSubject(custom, original string) string {
|
||||
if s := strings.TrimSpace(custom); s != "" {
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(original, "Fwd: ") {
|
||||
return original
|
||||
}
|
||||
return "Fwd: " + original
|
||||
}
|
||||
|
||||
// doForward 是 Agent 与人类两条转发路径的公共实现。
|
||||
// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。
|
||||
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
|
||||
var req forwardRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.To) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrMailNotFound):
|
||||
Error(w, http.StatusNotFound, "待转发的邮件不存在")
|
||||
return
|
||||
case errors.Is(err, repo.ErrForwardNotAllowed):
|
||||
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "Failed to load mail")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if !isAgent && user != nil {
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias)
|
||||
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) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"发信配额已用尽(%d/%d)。请先向人类发送最终总结,或联系管理员重置配额。",
|
||||
quota.Used, quota.Max))
|
||||
return
|
||||
}
|
||||
if qErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check quota")
|
||||
return
|
||||
}
|
||||
} else if user != nil {
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
}
|
||||
|
||||
body := quoteBody(src)
|
||||
if c := strings.TrimSpace(req.Comment); c != "" {
|
||||
body = c + "\n\n" + body
|
||||
}
|
||||
attachedCount := 0
|
||||
|
||||
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
|
||||
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
|
||||
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
|
||||
// 内容寻址下这只是新增元数据,不拷磁盘文件。
|
||||
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "复制附件失败")
|
||||
return
|
||||
} else {
|
||||
attachedCount = n
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail_id": newID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
"forwarded_from": src.ID.String(),
|
||||
"attachments": attachedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
|
||||
func ForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, agentName, agentName, true)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
|
||||
func MeForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, user.Username, "", false)
|
||||
}
|
||||
|
||||
// ---------- 配额管理(管理员) ----------
|
||||
|
||||
// GET /api/v1/admin/quotas
|
||||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
quotas, err := repo.ListQuotas(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list quotas")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quotas": quotas})
|
||||
}
|
||||
|
||||
type setQuotaRequest struct {
|
||||
// MaxRounds 发信配额上限;0 = 不限
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 为 true 时把已用次数归零
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/quotas/{name}
|
||||
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setQuotaRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds == nil && !req.Reset {
|
||||
Error(w, http.StatusBadRequest, "需要 max_rounds 或 reset 之一")
|
||||
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
|
||||
}
|
||||
}
|
||||
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})
|
||||
}
|
||||
77
gateway/internal/handler/forward_test.go
Normal file
77
gateway/internal/handler/forward_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。
|
||||
func TestForwardSubject(t *testing.T) {
|
||||
cases := []struct{ custom, original, want string }{
|
||||
{"", "修复登录态", "Fwd: 修复登录态"},
|
||||
{"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加
|
||||
{"自定义主题", "修复登录态", "自定义主题"},
|
||||
{" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := forwardSubject(c.custom, c.original); got != c.want {
|
||||
t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 引用块必须逐行加 "> ":原文含代码块或列表时,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func TestQuoteBodyPrefixesEveryLine(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
ID: uuid.New(),
|
||||
FromName: "opencode",
|
||||
FromWorkspace: "/root",
|
||||
Subject: "巡检结果",
|
||||
Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项",
|
||||
CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC),
|
||||
CCList: []models.Address{
|
||||
{Name: "pi", Path: "root", Raw: "pi@root.new"},
|
||||
},
|
||||
}
|
||||
|
||||
out := quoteBody(m)
|
||||
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
if line == "---" || line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, ">") {
|
||||
t.Errorf("引用块出现未加前缀的行: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// 元信息必须齐全,否则收件人不知道这封转发的来路
|
||||
for _, want := range []string{"opencode@/root", "巡检结果", "2026-09-02 10:30:00", "pi@root.new"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("引用块缺少 %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 原文正文本身要在引用里
|
||||
if !strings.Contains(out, "> fmt.Println(1)") {
|
||||
t.Errorf("原文代码行未被引用:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 无抄送时不该渲染出空的「抄送」行。
|
||||
func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
FromName: "admin",
|
||||
Subject: "x",
|
||||
Body: "y",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if strings.Contains(quoteBody(m), "抄送") {
|
||||
t.Error("无抄送时不应出现「抄送」行")
|
||||
}
|
||||
}
|
||||
128
gateway/internal/handler/helpers.go
Normal file
128
gateway/internal/handler/helpers.go
Normal file
@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// JSON 写入 JSON 响应
|
||||
func JSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// Error 写入错误响应
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Decode 从请求体解析 JSON
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// httpError 携带 HTTP 状态码的错误
|
||||
type httpError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e httpError) Error() string { return e.msg }
|
||||
|
||||
func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} }
|
||||
func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} }
|
||||
func errConflict(msg string) error { return httpError{http.StatusConflict, msg} }
|
||||
|
||||
// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。
|
||||
// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。
|
||||
func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)")
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
Error(w, http.StatusUnauthorized, "密钥已过期")
|
||||
case errors.Is(err, repo.ErrKeyNotFound):
|
||||
Error(w, http.StatusUnauthorized, "密钥无效")
|
||||
case errors.Is(err, repo.ErrKeyTypeInvalid):
|
||||
Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed")
|
||||
case errors.Is(err, repo.ErrKeyNeedsExpiry):
|
||||
Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours")
|
||||
case errors.Is(err, repo.ErrKeyTooShort):
|
||||
Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)")
|
||||
case errors.Is(err, repo.ErrKeyTokenTaken):
|
||||
Error(w, http.StatusConflict, "该密钥已登记过")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
// "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。
|
||||
func validateSessionAlias(alias string) error {
|
||||
if alias == "new" {
|
||||
return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`)
|
||||
}
|
||||
if strings.ContainsAny(alias, ". \t/@") {
|
||||
return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。
|
||||
//
|
||||
// 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让
|
||||
// name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。
|
||||
// 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。
|
||||
func normalizeAlias(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "new" {
|
||||
return "session-new"
|
||||
}
|
||||
// VARCHAR(128) 上限,按字节截断时不能切坏多字节字符
|
||||
const maxBytes = 128
|
||||
if len(out) > maxBytes {
|
||||
cut := out[:maxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
out = strings.Trim(cut, "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案
|
||||
func writeErr(w http.ResponseWriter, err error, fallback string) {
|
||||
if he, ok := err.(httpError); ok {
|
||||
Error(w, he.status, he.msg)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, fallback)
|
||||
}
|
||||
|
||||
// emptySlice 把 nil slice 转为空 JSON 数组 []
|
||||
func emptySlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
179
gateway/internal/handler/keys.go
Normal file
179
gateway/internal/handler/keys.go
Normal file
@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// ---------- 密钥管理 ----------
|
||||
//
|
||||
// 两套接口,权限边界不同:
|
||||
// /admin/agent-keys —— 管理员签发 Agent 接入密钥
|
||||
// /me/keys —— 用户自助签发客户端连接密钥(不能注册 Agent)
|
||||
//
|
||||
// 密钥全文只在创建响应里出现一次,列表接口只给前 8 位 hint。
|
||||
|
||||
type createKeyRequest struct {
|
||||
// AgentName 仅 Agent 密钥使用;留空表示「待绑定」,首次注册时按注册请求的 name 落定
|
||||
AgentName string `json:"agent_name"`
|
||||
// Label 人类可读备注(如「我的笔记本」「CI 机器」)
|
||||
Label string `json:"label"`
|
||||
// KeyType permanent / one_time / timed
|
||||
KeyType string `json:"key_type"`
|
||||
// ExpiresHours 仅 timed 使用,必须为正
|
||||
ExpiresHours int `json:"expires_hours"`
|
||||
// KeyToken 仅 Agent 密钥使用:登记一把客户端已在本地生成的密钥。
|
||||
// 插件首次安装时自己生成密钥并打印出来,管理员把它填到这里完成登记,
|
||||
// 密钥全文因此不需要从服务器往客户端传。留空则由服务器生成。
|
||||
KeyToken string `json:"key_token"`
|
||||
}
|
||||
|
||||
// normalizeKeyType 默认给 permanent,避免调用方漏填时落到非法值
|
||||
func normalizeKeyType(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return models.KeyPermanent
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys
|
||||
func CreateAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
admin := middleware.GetUser(r)
|
||||
if admin == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateAgentKey(r.Context(),
|
||||
strings.TrimSpace(req.AgentName), normalizeKeyType(req.KeyType),
|
||||
strings.TrimSpace(req.Label), req.ExpiresHours, admin.ID,
|
||||
strings.TrimSpace(req.KeyToken))
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一一次回传全文
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/agent-keys?agent_name=xxx
|
||||
func ListAgentKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := repo.ListAgentKeys(r.Context(), r.URL.Query().Get("agent_name"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agent-keys/{id}
|
||||
func DeleteAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := repo.DeleteAgentKey(r.Context(), id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type bindKeyRequest struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys/{id}/bind
|
||||
func BindAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req bindKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.AgentName)
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent_name")
|
||||
return
|
||||
}
|
||||
if err := repo.BindAgentKey(r.Context(), id, name); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "bound", "agent_name": name})
|
||||
}
|
||||
|
||||
// ---------- 用户连接密钥 ----------
|
||||
|
||||
// POST /api/v1/me/keys
|
||||
func CreateMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateUserKey(r.Context(), user.ID,
|
||||
strings.TrimSpace(req.Label), normalizeKeyType(req.KeyType), req.ExpiresHours)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/keys
|
||||
func ListMyKeys(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
keys, err := repo.ListUserKeys(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/keys/{id}
|
||||
func DeleteMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// repo 层带 user_id 条件,删不到就是不属于自己或不存在,统一 404
|
||||
if err := repo.DeleteUserKey(r.Context(), user.ID, id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
421
gateway/internal/handler/mail.go
Normal file
421
gateway/internal/handler/mail.go
Normal file
@ -0,0 +1,421 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- Mail ----------
|
||||
|
||||
type sendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名,
|
||||
// 之后即可用 name@path.<alias> 续谈。命中已有会话时该字段被忽略。
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /attachments 上传拿到的 id;只能附加自己上传且未挂载的
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
|
||||
// Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。
|
||||
//
|
||||
// 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。
|
||||
// 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。
|
||||
//
|
||||
// RelayKey 必須是上游那条消息的稳定标识(permission id / assistant message id):
|
||||
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
|
||||
Relay string `json:"relay"` // "" | "permission" | "summary"
|
||||
RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
//
|
||||
// session 位三态语义(设计文档):
|
||||
// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立
|
||||
// - new(pi@root.new) → 强制新建一个会话
|
||||
// - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达
|
||||
//
|
||||
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
|
||||
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string) (uuid.UUID, *uuid.UUID, error) {
|
||||
if replyTo != "" {
|
||||
replyID, err := uuid.Parse(replyTo)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
|
||||
}
|
||||
mail, err := repo.GetMailByID(r.Context(), replyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errNotFound("Parent mail not found")
|
||||
}
|
||||
repo.TouchSession(r.Context(), mail.SessionID)
|
||||
return mail.SessionID, &replyID, nil
|
||||
}
|
||||
|
||||
switch addr.Mode() {
|
||||
case models.SessionNew:
|
||||
// 新建会话:若调用方给了别名,当场命名,之后即可用 name@path.<alias> 续谈。
|
||||
// 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。
|
||||
var aliasPtr *string
|
||||
if a := strings.TrimSpace(alias); a != "" {
|
||||
if err := validateSessionAlias(a); err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
|
||||
return uuid.Nil, nil, errConflict(fmt.Sprintf(
|
||||
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
|
||||
}
|
||||
aliasPtr = &a
|
||||
}
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject)
|
||||
return id, nil, err
|
||||
|
||||
case models.SessionDefault:
|
||||
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
return id, nil, err
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/send
|
||||
func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req sendMailRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。
|
||||
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
|
||||
//
|
||||
// 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信,
|
||||
// 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。
|
||||
relay, relayKey, err := parseRelay(req.Relay, req.RelayKey)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Invalid relay")
|
||||
return
|
||||
}
|
||||
|
||||
var quota repo.Quota
|
||||
var budget repo.SessionBudget
|
||||
if relay != "" {
|
||||
// 先占幂等键。重复则说明这条上游消息已经转过,
|
||||
// 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。
|
||||
if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil {
|
||||
if errors.Is(cErr, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay": relay,
|
||||
"relay_key": relayKey,
|
||||
"detail": "该上游消息已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
// 仅读快照用于回传,不扣任何一层
|
||||
quota, _ = repo.GetQuota(r.Context(), agentName)
|
||||
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
|
||||
} else {
|
||||
// 两层都要过:会话预算管「这件事值得多少个来回」,
|
||||
// Agent 全局配额管「这个 Agent 总共能发多少」。
|
||||
// 先扣会话、后扣全局;全局拦下时把会话那次退回去 ——
|
||||
// 那次往返实际上没有发生,不能白掉一格。
|
||||
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+
|
||||
"若需继续主动发信,请让人在对话页调高本会话的预算。",
|
||||
budget.Used, budget.Max))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Agent 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。
|
||||
//
|
||||
// 提议只是提议 —— 别名是人的寻址入口,Agent 干到一半自己改掉会让人
|
||||
// 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。
|
||||
proposal, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
agentName, agentName, to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
// 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
if relay != "" {
|
||||
// 关联失败不影响功能,只是少一条审计记录
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if proposal != nil {
|
||||
// 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息
|
||||
_ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason)
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, agentName) {
|
||||
return
|
||||
}
|
||||
|
||||
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 更应该看这个而不是全局配额
|
||||
if !budget.Unlimited {
|
||||
resp["budget_remaining"] = budget.Remaining
|
||||
resp["budget_used"] = budget.Used
|
||||
resp["budget_max"] = budget.Max
|
||||
}
|
||||
if relay != "" {
|
||||
// 告知本次未扣配额,否则插件看到 quota_remaining 没变会以为数据错了
|
||||
resp["relay"] = relay
|
||||
resp["quota_charged"] = false
|
||||
}
|
||||
if proposal != nil {
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过,
|
||||
// 让它知道最终会拿什么去问用户
|
||||
resp["rename_proposed"] = proposal.Alias
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// notifyRecipients 向主收件人与抄送方推送 new_mail,并刷新相关方的会话列表。
|
||||
// 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间),
|
||||
// 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。
|
||||
func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
|
||||
payload := func(role string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"from_name": from,
|
||||
"subject": subject,
|
||||
"mail_type": "normal",
|
||||
"role": role, // to / cc
|
||||
}
|
||||
}
|
||||
|
||||
update := map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
|
||||
seen := map[string]bool{}
|
||||
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to"))
|
||||
sse.Default.SendToRecipient(to.Name, "session_update", update)
|
||||
seen[to.Name] = true
|
||||
|
||||
for _, c := range cc {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc"))
|
||||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||||
}
|
||||
|
||||
if !seen[from] {
|
||||
sse.Default.SendToRecipient(from, "session_update", update)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/inbox
|
||||
func GetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "unread"
|
||||
}
|
||||
limit := 10
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), agentName, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
total, _ := repo.CountUnread(r.Context(), agentName)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限
|
||||
func GetMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
fillAttachments(r, mail)
|
||||
JSON(w, http.StatusOK, mail)
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件
|
||||
func MarkMailRead(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权操作该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.MarkMailRead(r.Context(), mailID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "read"})
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, nil
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
285
gateway/internal/handler/me.go
Normal file
285
gateway/internal/handler/me.go
Normal file
@ -0,0 +1,285 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ----------
|
||||
|
||||
type meSendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session
|
||||
CC string `json:"cc"` // 多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
// MaxRounds 是本次任务的往返预算(0/省略 = 不限)。
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
|
||||
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
|
||||
// PUT /sessions/{id}/budget(对话页里可随时改)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/send
|
||||
func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req meSendMailRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// human@ 是兼容别名,人类发信时解析为自己
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
|
||||
// 权限边界:校验可调用的 Agent 与可访问的目录
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// 人类发起的会话归属于该用户
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
|
||||
// 新建会话时接受往返预算。只在新建时设:续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
|
||||
if req.MaxRounds != nil && parentMailID == nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
if _, err := repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set session budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
|
||||
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
|
||||
_, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, user.Username) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject)
|
||||
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
|
||||
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
|
||||
resp["budget_max"] = b.Max
|
||||
resp["budget_used"] = b.Used
|
||||
resp["budget_remaining"] = b.Remaining
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/inbox
|
||||
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// 列表页要显示附件图标与下载入口
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
total, _ := repo.CountUnread(r.Context(), user.Username)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/sent
|
||||
func MeGetSent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
|
||||
if err == nil {
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sent")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/sessions
|
||||
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sessions")
|
||||
return
|
||||
}
|
||||
|
||||
type SessionOut struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
SessionAlias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
|
||||
result := make([]SessionOut, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
|
||||
result = append(result, SessionOut{
|
||||
SessionID: s.ID,
|
||||
SessionAlias: s.Alias,
|
||||
FromAgent: s.FromAgent,
|
||||
Subject: s.Subject,
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
MailCount: s.MailCount,
|
||||
UnreadCount: unread,
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sessions": result,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
|
||||
func resolveHumanAlias(a models.Address, username string) models.Address {
|
||||
if a.Name != "human" {
|
||||
return a
|
||||
}
|
||||
a.Name = username
|
||||
a.Raw = username + "@" + a.Path
|
||||
if a.Session != "" {
|
||||
a.Raw += "." + a.Session
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
|
||||
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
|
||||
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
|
||||
if user.IsAdmin() {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if a.Name == "" || a.Name == user.Username {
|
||||
continue
|
||||
}
|
||||
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
|
||||
if err != nil {
|
||||
return "无法校验收件人权限"
|
||||
}
|
||||
if !isHuman && !user.CanUseAgent(a.Name) {
|
||||
return "无权调用 Agent: " + a.Name
|
||||
}
|
||||
if !user.CanUsePath(a.Path) {
|
||||
return "无权访问目录: " + a.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
279
gateway/internal/handler/permission.go
Normal file
279
gateway/internal/handler/permission.go
Normal file
@ -0,0 +1,279 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
type permissionRequestRequest struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
SessionID *string `json:"session_id"`
|
||||
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
|
||||
To string `json:"to"`
|
||||
// RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。
|
||||
//
|
||||
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
|
||||
// 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放,
|
||||
// 没有幂等键就会给同一次询问生成好几封邮件。
|
||||
RelayKey string `json:"relay_key"`
|
||||
}
|
||||
|
||||
type permissionDecideRequest struct {
|
||||
MailID string `json:"mail_id"`
|
||||
Decision string `json:"decision"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/request
|
||||
func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionRequestRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Question == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing question")
|
||||
return
|
||||
}
|
||||
|
||||
options := req.Options
|
||||
if len(options) == 0 {
|
||||
options = []string{"同意", "拒绝"}
|
||||
}
|
||||
|
||||
// 幂等:同一条上游询问只生成一封邮件。
|
||||
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
|
||||
relayKey := strings.TrimSpace(req.RelayKey)
|
||||
if relayKey != "" {
|
||||
if len(relayKey) > 160 {
|
||||
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
|
||||
return
|
||||
}
|
||||
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
|
||||
if errors.Is(err, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay_key": relayKey,
|
||||
"detail": "该权限询问已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确定 session
|
||||
var sessionID uuid.UUID
|
||||
if req.SessionID != nil && *req.SessionID != "" {
|
||||
id, err := uuid.Parse(*req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
repo.TouchSession(r.Context(), sessionID)
|
||||
} else {
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
}
|
||||
|
||||
// 决策人:显式指定优先,否则取会话 owner
|
||||
decider := req.To
|
||||
if decider == "" || decider == "human" {
|
||||
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
|
||||
if err == nil && owner != "" {
|
||||
decider = owner
|
||||
}
|
||||
}
|
||||
if decider == "" {
|
||||
// 会话无归属(Agent 自发起)时退回默认管理员
|
||||
admin, err := repo.FirstAdminUsername(r.Context())
|
||||
if err != nil || admin == "" {
|
||||
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
|
||||
return
|
||||
}
|
||||
decider = admin
|
||||
}
|
||||
|
||||
body := req.Context
|
||||
if body == "" {
|
||||
body = req.Question
|
||||
}
|
||||
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options)
|
||||
if err != nil {
|
||||
// 归还幂等键,否则这次询问永远转不出来了
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
|
||||
return
|
||||
}
|
||||
if relayKey != "" {
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission request")
|
||||
return
|
||||
}
|
||||
|
||||
// 只推给该决策人
|
||||
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"from_name": agentName,
|
||||
"subject": "权限请求: " + req.Question,
|
||||
"mail_type": "permission_request",
|
||||
"role": "to",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"permission_mail_id": mailID.String(),
|
||||
"decider": decider,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
|
||||
func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionDecideRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MailID == "" || req.Decision == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
|
||||
return
|
||||
}
|
||||
|
||||
mailID, err := uuid.Parse(req.MailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Permission request not found")
|
||||
return
|
||||
}
|
||||
if perm.Result != nil && *perm.Result != "" {
|
||||
Error(w, http.StatusConflict, "该请求已被处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:必须是这封权限邮件的收件人,或管理员
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin() && mail.ToName != user.Username {
|
||||
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
|
||||
return
|
||||
}
|
||||
|
||||
// 决策选项必须在候选内
|
||||
if !contains(perm.Options, req.Decision) {
|
||||
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to decide permission")
|
||||
return
|
||||
}
|
||||
|
||||
decisionMailID, err := repo.CreateDecisionMail(
|
||||
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 通知发起 Agent 恢复执行
|
||||
// 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。
|
||||
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
|
||||
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
|
||||
payload := map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
"decision": req.Decision,
|
||||
"note": req.Note,
|
||||
"decided_by": user.Username,
|
||||
}
|
||||
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
|
||||
payload["relay_key"] = key
|
||||
payload["relay_kind"] = kind
|
||||
}
|
||||
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
|
||||
// 只刷新决策人自己的界面
|
||||
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
|
||||
"session_id": perm.SessionID.String(),
|
||||
"status": "active",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "decided",
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
|
||||
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
forUser := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
forUser = ""
|
||||
}
|
||||
|
||||
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"requests": emptySlice(reqs),
|
||||
})
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
87
gateway/internal/handler/ratelimit.go
Normal file
87
gateway/internal/handler/ratelimit.go
Normal file
@ -0,0 +1,87 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
lockoutDuration = 5 * time.Minute
|
||||
failureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
type failureRecord struct {
|
||||
count int
|
||||
firstSeen time.Time
|
||||
lockedAt time.Time
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
recs map[string]*failureRecord
|
||||
}
|
||||
|
||||
var limiter = &loginLimiter{recs: make(map[string]*failureRecord)}
|
||||
|
||||
// Locked 返回该用户名是否处于锁定期,以及剩余秒数
|
||||
func (l *loginLimiter) Locked(name string) (bool, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
r, ok := l.recs[name]
|
||||
if !ok || r.lockedAt.IsZero() {
|
||||
return false, 0
|
||||
}
|
||||
elapsed := time.Since(r.lockedAt)
|
||||
if elapsed >= lockoutDuration {
|
||||
delete(l.recs, name)
|
||||
return false, 0
|
||||
}
|
||||
return true, int((lockoutDuration - elapsed).Seconds())
|
||||
}
|
||||
|
||||
// Fail 记录一次失败,达到阈值则锁定
|
||||
func (l *loginLimiter) Fail(name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
r, ok := l.recs[name]
|
||||
if !ok || now.Sub(r.firstSeen) > failureWindow {
|
||||
l.recs[name] = &failureRecord{count: 1, firstSeen: now}
|
||||
return
|
||||
}
|
||||
r.count++
|
||||
if r.count >= maxLoginFailures {
|
||||
r.lockedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 登录成功后清除失败计数
|
||||
func (l *loginLimiter) Reset(name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.recs, name)
|
||||
}
|
||||
|
||||
// 定期清理过期记录,避免 map 无限增长
|
||||
func init() {
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
limiter.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, r := range limiter.recs {
|
||||
stale := now.Sub(r.firstSeen) > failureWindow &&
|
||||
(r.lockedAt.IsZero() || now.Sub(r.lockedAt) > lockoutDuration)
|
||||
if stale {
|
||||
delete(limiter.recs, k)
|
||||
}
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
85
gateway/internal/handler/relay_test.go
Normal file
85
gateway/internal/handler/relay_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseRelay 是免配额通道的入口校验。白名单 + 强制幂等键这两条必须守住:
|
||||
// 前者防止 relay 变成任意字符串的后门,后者是「同一条上游消息只转一次」的基础。
|
||||
func TestParseRelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind, key string
|
||||
wantKind string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "都为空 = 普通自主发信,正常扣配额", kind: "", key: "", wantKind: "", wantKey: ""},
|
||||
{name: "总结转发", kind: "summary", key: "msg_1", wantKind: "summary", wantKey: "msg_1"},
|
||||
{name: "权限转发", kind: "permission", key: "per_1", wantKind: "permission", wantKey: "per_1"},
|
||||
{name: "两端空白被裁掉", kind: " summary ", key: " msg_2 ", wantKind: "summary", wantKey: "msg_2"},
|
||||
|
||||
// 白名单外的类型必须拒:否则 relay:"anything" 就绕过了配额
|
||||
{name: "未知类型", kind: "whatever", key: "k", wantErr: true},
|
||||
// 没有幂等键就无法阻止同一条上游消息反复转发
|
||||
{name: "缺幂等键", kind: "summary", key: "", wantErr: true},
|
||||
{name: "只给了键没给类型", kind: "", key: "k", wantErr: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
kind, key, err := parseRelay(c.kind, c.key)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("期望报错,实际通过:kind=%q key=%q", kind, key)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("意外报错: %v", err)
|
||||
}
|
||||
if kind != c.wantKind || key != c.wantKey {
|
||||
t.Fatalf("得到 (%q, %q),期望 (%q, %q)", kind, key, c.wantKind, c.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRelayRejectsOverlongKey(t *testing.T) {
|
||||
long := make([]byte, 161)
|
||||
for i := range long {
|
||||
long[i] = 'k'
|
||||
}
|
||||
if _, _, err := parseRelay("summary", string(long)); err == nil {
|
||||
t.Fatal("超长 relay_key 应被拒绝(列宽 160)")
|
||||
}
|
||||
}
|
||||
|
||||
// 免配额类型是白名单,不是黑名单。新增一种转发时必须同时更新这里,
|
||||
// 免得悄悄多出一条不受审视的免费通道。
|
||||
func TestRelayKindsIsExactlyTwo(t *testing.T) {
|
||||
want := map[string]bool{"permission": true, "summary": true}
|
||||
if len(relayKinds) != len(want) {
|
||||
t.Fatalf("免配额类型数量变了:%v。新增前请确认它确实是 harness 代劳而非模型自主发信", relayKinds)
|
||||
}
|
||||
for k := range want {
|
||||
if !relayKinds[k] {
|
||||
t.Fatalf("缺少免配额类型 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报错必须是 400 而不是 500:这些都是调用方参数问题
|
||||
func TestParseRelayErrorsAreBadRequest(t *testing.T) {
|
||||
for _, c := range [][2]string{{"whatever", "k"}, {"summary", ""}, {"", "k"}} {
|
||||
_, _, err := parseRelay(c[0], c[1])
|
||||
if err == nil {
|
||||
t.Fatalf("(%q,%q) 应报错", c[0], c[1])
|
||||
}
|
||||
var he httpError
|
||||
if !errors.As(err, &he) || he.status != 400 {
|
||||
t.Fatalf("(%q,%q) 的错误不是 400: %#v", c[0], c[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
gateway/internal/handler/rename_proposal.go
Normal file
141
gateway/internal/handler/rename_proposal.go
Normal file
@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------- Agent 在正文里提议改会话别名 ----------
|
||||
//
|
||||
// 与「平台命名自动同步」(POST /sessions/:id/sync)互补:
|
||||
// 自动同步 = 平台起的名字,后台静默生效,不打扰人
|
||||
// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头
|
||||
//
|
||||
// 为什么走正文而不是让 Agent 直接调 PUT alias:
|
||||
// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。
|
||||
// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
//
|
||||
// 载体选 HTML 注释:
|
||||
// - react-markdown 默认不解析 raw HTML,注释在页面上不可见(实测渲染为转义文本节点,
|
||||
// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它)
|
||||
// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼
|
||||
// - 不与 Markdown 语法冲突,不会被格式化工具改写
|
||||
|
||||
// renameProposalRe 匹配 Agent 提议改名的标记。
|
||||
//
|
||||
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
|
||||
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
|
||||
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
|
||||
//
|
||||
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
|
||||
var renameProposalRe = regexp.MustCompile(
|
||||
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
|
||||
|
||||
// RenameProposal 是从正文里解析出的一条改名提议。
|
||||
type RenameProposal struct {
|
||||
// Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias
|
||||
Alias string `json:"alias"`
|
||||
// Reason 是 Agent 给出的理由,可为空
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。
|
||||
//
|
||||
// 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。
|
||||
// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本)。
|
||||
//
|
||||
// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉:
|
||||
// 与其在正文里留一行乱码,不如当它没提。
|
||||
func extractRenameProposal(body string) (*RenameProposal, string) {
|
||||
matches := renameProposalRe.FindAllStringSubmatch(body, -1)
|
||||
cleaned := stripProposalMarkers(body)
|
||||
if len(matches) == 0 {
|
||||
return nil, cleaned
|
||||
}
|
||||
|
||||
last := matches[len(matches)-1]
|
||||
alias := normalizeAlias(strings.TrimSpace(last[1]))
|
||||
if alias == "" {
|
||||
return nil, cleaned
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
return nil, cleaned
|
||||
}
|
||||
reason := ""
|
||||
if len(last) > 2 {
|
||||
reason = strings.TrimSpace(last[2])
|
||||
}
|
||||
// 理由是展示给人看的一句话,过长会把提示条撑破
|
||||
const maxReason = 200
|
||||
if len(reason) > maxReason {
|
||||
reason = preview(reason, maxReason)
|
||||
}
|
||||
return &RenameProposal{Alias: alias, Reason: reason}, cleaned
|
||||
}
|
||||
|
||||
// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。
|
||||
func stripProposalMarkers(body string) string {
|
||||
out := renameProposalRe.ReplaceAllString(body, "")
|
||||
// 标记独占一行时会留下连续空行,压成一个空行(Markdown 的段落分隔)
|
||||
for strings.Contains(out, "\n\n\n") {
|
||||
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo
|
||||
// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && s[cut]&0xC0 == 0x80 {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// ---------- 插件代劳转发(免配额通道) ----------
|
||||
|
||||
// relayKinds 是允许免配额的转发类型。
|
||||
//
|
||||
// 白名单而不是任意字符串:免配额通道必须有明确边界,
|
||||
// 否则 `relay: "whatever"` 就成了绕过配额的后门。
|
||||
//
|
||||
// permission —— 平台原生的权限询问(opencode 的 permission.updated)。
|
||||
// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。
|
||||
// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。
|
||||
// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时
|
||||
// Agent 连交代都做不了。
|
||||
var relayKinds = map[string]bool{
|
||||
"permission": true,
|
||||
"summary": true,
|
||||
}
|
||||
|
||||
// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。
|
||||
// 两者都为空表示这是普通的自主发信,正常扣配额。
|
||||
func parseRelay(kind, key string) (string, string, error) {
|
||||
kind = strings.TrimSpace(kind)
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
if kind == "" {
|
||||
if key != "" {
|
||||
return "", "", errBadRequest("给了 relay_key 却没给 relay 类型")
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
if !relayKinds[kind] {
|
||||
return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`)
|
||||
}
|
||||
// 幂等键是免配额通道的唯一约束基础,不能省:
|
||||
// 没有它就无法阻止同一条上游消息被反复转发。
|
||||
if key == "" {
|
||||
return "", "", errBadRequest("relay 转发必须带 relay_key(上游消息的稳定 id)")
|
||||
}
|
||||
if len(key) > 160 {
|
||||
return "", "", errBadRequest("relay_key 过长(上限 160 字节)")
|
||||
}
|
||||
return kind, key, nil
|
||||
}
|
||||
143
gateway/internal/handler/rename_proposal_test.go
Normal file
143
gateway/internal/handler/rename_proposal_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractRenameProposal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantAlias string
|
||||
wantReason string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "无标记时原样返回",
|
||||
body: "普通正文。",
|
||||
wantAlias: "",
|
||||
wantBody: "普通正文。",
|
||||
},
|
||||
{
|
||||
name: "带理由",
|
||||
body: "已定位问题。\n\n<!-- agentmail:rename-session alias=\"fix-login-leak\" reason=\"是登录态泄漏\" -->",
|
||||
wantAlias: "fix-login-leak",
|
||||
wantReason: "是登录态泄漏",
|
||||
wantBody: "已定位问题。",
|
||||
},
|
||||
{
|
||||
name: "无理由",
|
||||
body: "<!-- agentmail:rename-session alias=\"cache-eval\" -->\n\n正文在后。",
|
||||
wantAlias: "cache-eval",
|
||||
wantBody: "正文在后。",
|
||||
},
|
||||
{
|
||||
// Agent 在长回复里反复修正措辞,最后写下的才是它的结论
|
||||
name: "多条只取最后一条",
|
||||
body: "<!-- agentmail:rename-session alias=\"first\" -->\n中间\n<!-- agentmail:rename-session alias=\"second\" -->",
|
||||
wantAlias: "second",
|
||||
wantBody: "中间",
|
||||
},
|
||||
{
|
||||
// 别名含 . / @ 会让三维地址切分歧义,normalizeAlias 改写为 -
|
||||
name: "非法字符被规范化",
|
||||
body: "<!-- agentmail:rename-session alias=\"fix login.leak/now\" -->",
|
||||
wantAlias: "fix-login-leak-now",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// "new" 是寻址保留字
|
||||
name: "保留字被改写",
|
||||
body: "<!-- agentmail:rename-session alias=\"new\" -->",
|
||||
wantAlias: "session-new",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// 规范化后为空 → 视为无提议,但标记仍要剥掉
|
||||
name: "空别名视为无提议且剥掉标记",
|
||||
body: "正文\n<!-- agentmail:rename-session alias=\"\" -->",
|
||||
wantAlias: "",
|
||||
wantBody: "正文",
|
||||
},
|
||||
{
|
||||
name: "多余空格容错",
|
||||
body: "<!-- agentmail:rename-session alias=\"ok-name\" -->",
|
||||
wantAlias: "ok-name",
|
||||
wantBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, body := extractRenameProposal(c.body)
|
||||
gotAlias := ""
|
||||
gotReason := ""
|
||||
if p != nil {
|
||||
gotAlias, gotReason = p.Alias, p.Reason
|
||||
}
|
||||
if gotAlias != c.wantAlias {
|
||||
t.Errorf("alias = %q,期望 %q", gotAlias, c.wantAlias)
|
||||
}
|
||||
if gotReason != c.wantReason {
|
||||
t.Errorf("reason = %q,期望 %q", gotReason, c.wantReason)
|
||||
}
|
||||
if body != c.wantBody {
|
||||
t.Errorf("剥标记后正文 = %q,期望 %q", body, c.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 标记必须从入库正文里彻底消失:react-markdown 不解析 raw HTML,
|
||||
// 留着会被转义成一行可见的乱码文本,而不是被渲染器吞掉。
|
||||
func TestProposalMarkerNeverSurvivesInBody(t *testing.T) {
|
||||
bodies := []string{
|
||||
"<!-- agentmail:rename-session alias=\"a\" -->",
|
||||
"前\n<!-- agentmail:rename-session alias=\"a\" reason=\"r\" -->\n后",
|
||||
"<!-- agentmail:rename-session alias=\"\" -->", // 无效提议也要剥
|
||||
}
|
||||
for _, b := range bodies {
|
||||
_, out := extractRenameProposal(b)
|
||||
if renameProposalRe.MatchString(out) {
|
||||
t.Errorf("正文里仍残留标记:%q", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提议出来的别名必须能直接通过 PUT alias 的校验,
|
||||
// 否则前端点「接受」时会拿到 400 —— 系统自己造出了自己拒绝的值。
|
||||
func TestProposedAliasPassesValidation(t *testing.T) {
|
||||
inputs := []string{
|
||||
"fix login.leak",
|
||||
"new",
|
||||
"a@b/c",
|
||||
" spaced name ",
|
||||
"正常中文别名",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"" + in + "\" -->")
|
||||
if p == nil {
|
||||
continue // 规范化后为空,已按无提议处理
|
||||
}
|
||||
if err := validateSessionAlias(p.Alias); err != nil {
|
||||
t.Errorf("提议 %q → %q 未通过 validateSessionAlias: %v", in, p.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonTruncatedOnUTF8Boundary(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "很长的理由"
|
||||
}
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"x\" reason=\"" + long + "\" -->")
|
||||
if p == nil {
|
||||
t.Fatal("应当解析出提议")
|
||||
}
|
||||
if len(p.Reason) > 210 { // 200 + "..."
|
||||
t.Errorf("理由未截断:%d 字节", len(p.Reason))
|
||||
}
|
||||
for _, r := range p.Reason {
|
||||
if r == 0xFFFD {
|
||||
t.Fatal("截断产生了替换符,说明切在多字节字符中间")
|
||||
}
|
||||
}
|
||||
}
|
||||
340
gateway/internal/handler/sessions.go
Normal file
340
gateway/internal/handler/sessions.go
Normal file
@ -0,0 +1,340 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Session(均需登录,且做会话级鉴权) ----------
|
||||
|
||||
// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问
|
||||
func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return sessionID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}
|
||||
func GetSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get session mails")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"session": session,
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/mails
|
||||
func GetSessionMails(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get mails")
|
||||
return
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
type updateAliasRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/alias
|
||||
//
|
||||
// 会话别名负责三维寻址(name@path.<alias>),因此必须全局唯一,
|
||||
// 且不能叫 "new"(那是寻址保留字)。
|
||||
func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAliasRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
alias := strings.TrimSpace(req.Alias)
|
||||
if alias == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing alias")
|
||||
return
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
writeErr(w, err, "Invalid alias")
|
||||
return
|
||||
}
|
||||
|
||||
// 被其他会话占用时报 409,而不是默默造出两个同名可寻址会话
|
||||
if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID {
|
||||
Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update alias")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "updated",
|
||||
"alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。
|
||||
//
|
||||
// 各 Agent 平台(opencode / Claude Code / DSH…)都会由模型为会话生成一个摘要标题,
|
||||
// 并配一个短 slug。不在本侧另造一套命名:平台那边叫什么,本侧就叫什么。
|
||||
type syncSessionRequest struct {
|
||||
// Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。
|
||||
Alias string `json:"alias"`
|
||||
// Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/sync
|
||||
//
|
||||
// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。
|
||||
// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一),
|
||||
// 因此本接口不会因撞名失败,响应里回传最终落库的别名。
|
||||
func SyncSession(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 只能同步自己参与过的会话
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权修改未参与的会话")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncSessionRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]string{"status": "synced"}
|
||||
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync title")
|
||||
return
|
||||
}
|
||||
resp["title"] = title
|
||||
}
|
||||
|
||||
if alias := strings.TrimSpace(req.Alias); alias != "" {
|
||||
// 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化
|
||||
norm := normalizeAlias(alias)
|
||||
if norm == "" {
|
||||
Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名")
|
||||
return
|
||||
}
|
||||
final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync alias")
|
||||
return
|
||||
}
|
||||
resp["alias"] = final
|
||||
}
|
||||
|
||||
// 让参与方前端立即看到新标题/别名
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"alias": resp["alias"],
|
||||
"title": resp["title"],
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/rename-proposal
|
||||
//
|
||||
// 返回该会话里最新一条尚未处理的改名提议(Agent 在正文里提的)。
|
||||
// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。
|
||||
// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。
|
||||
func GetRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"proposal": map[string]string{"alias": alias, "reason": reason},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/rename-proposal/dismiss
|
||||
//
|
||||
// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 ——
|
||||
// 否则每次打开会话都要重新点一次「忽略」。
|
||||
//
|
||||
// 接受提议走已有的 PUT /sessions/{id}/alias,不另开端点:
|
||||
// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
|
||||
func DismissRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
// 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "no_pending"})
|
||||
return
|
||||
}
|
||||
if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to dismiss proposal")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "dismissed",
|
||||
"dismissed": alias,
|
||||
})
|
||||
}
|
||||
|
||||
type sessionBudgetRequest struct {
|
||||
// MaxRounds 是本会话的往返预算上限(0 = 不限)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给:
|
||||
// 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:
|
||||
// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。
|
||||
func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方:
|
||||
// 人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req sessionBudgetRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds == nil && !req.Reset {
|
||||
Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset")
|
||||
return
|
||||
}
|
||||
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds != nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
|
||||
// 此时剩余为 0,Agent 下次发信即被拦。
|
||||
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Reset {
|
||||
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to reset budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"budget_max": b.Max,
|
||||
"budget_used": b.Used,
|
||||
"budget_remaining": b.Remaining,
|
||||
})
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
209
gateway/internal/handler/thread.go
Normal file
209
gateway/internal/handler/thread.go
Normal file
@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 对话树(按方向分块加载) ----------
|
||||
|
||||
// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 ——
|
||||
// 那就等于绕过了分块加载。
|
||||
const (
|
||||
threadDefaultLimit = 40
|
||||
threadMaxLimit = 200
|
||||
)
|
||||
|
||||
// threadNode 是返回给前端的树节点。
|
||||
//
|
||||
// Detached 表示「这封的父邮件当前不在返回集里」,两种原因:
|
||||
// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者)
|
||||
// - 父邮件还没加载(分块加载的边界,往上滑会补上)
|
||||
//
|
||||
// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。
|
||||
// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。
|
||||
type threadNode struct {
|
||||
repo.TreeMail
|
||||
Detached bool `json:"detached,omitempty"`
|
||||
// ParentHidden 为真表示父邮件确实存在但无权查看(不是尚未加载)
|
||||
ParentHidden bool `json:"parent_hidden,omitempty"`
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id}/thread
|
||||
//
|
||||
// 以给定邮件为锚点,按方向分块返回线索:
|
||||
//
|
||||
// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用
|
||||
// ?dir=up&offset=N 继续往上取祖先(上滑加载)
|
||||
// ?dir=down&offset=N 继续往下取子孙
|
||||
//
|
||||
// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数),
|
||||
// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。
|
||||
//
|
||||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||||
func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 先确认调用者确实看得到作为锚点的这封邮件,否则等于给了一个
|
||||
// 「随便报 mail_id 就能探测线索存在性」的接口
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir == "" {
|
||||
dir = "around"
|
||||
}
|
||||
if dir != "around" && dir != "up" && dir != "down" {
|
||||
Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down")
|
||||
return
|
||||
}
|
||||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||||
|
||||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||||
seen := map[uuid.UUID]bool{}
|
||||
canSee := func(sid uuid.UUID) bool {
|
||||
if v, ok := seen[sid]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
if err != nil {
|
||||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||||
}
|
||||
seen[sid] = v
|
||||
return v
|
||||
}
|
||||
|
||||
var (
|
||||
raw []repo.TreeMail
|
||||
hasMoreUp bool
|
||||
hasMoreDn bool
|
||||
wantUp = dir == "around" || dir == "up"
|
||||
wantDown = dir == "around" || dir == "down"
|
||||
upOffset = offset
|
||||
downOffset = offset
|
||||
)
|
||||
|
||||
// around 时两个方向各取一半,避免首屏一次要求 2×limit。
|
||||
// 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。
|
||||
upLimit, downLimit := limit, limit
|
||||
if dir == "around" {
|
||||
upLimit = limit / 2
|
||||
if upLimit < 1 {
|
||||
upLimit = 1
|
||||
}
|
||||
downLimit = limit - upLimit
|
||||
if downLimit < 1 {
|
||||
downLimit = 1
|
||||
}
|
||||
upOffset, downOffset = 0, 0
|
||||
}
|
||||
|
||||
if wantUp {
|
||||
anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load ancestors")
|
||||
return
|
||||
}
|
||||
raw = append(raw, anc...)
|
||||
hasMoreUp = more
|
||||
}
|
||||
if wantDown {
|
||||
// around 与 down&offset=0 会带上锚点自己(Depth 0);
|
||||
// up 方向单独请求时不带,前端已经有它了
|
||||
desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load descendants")
|
||||
return
|
||||
}
|
||||
raw = append(raw, desc...)
|
||||
hasMoreDn = more
|
||||
}
|
||||
|
||||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||||
// 父存在却不在集里,再判断是"无权看"还是"没加载"。
|
||||
visible := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
if canSee(m.SessionID) {
|
||||
visible[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []threadNode{}
|
||||
for _, m := range raw {
|
||||
if !visible[m.ID] {
|
||||
continue
|
||||
}
|
||||
n := threadNode{TreeMail: m}
|
||||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||||
n.Detached = true
|
||||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||||
// 完全没出现过 = 只是还没加载到,往上滑会补上
|
||||
for _, other := range raw {
|
||||
if other.ID == *m.ParentMailID {
|
||||
n.ParentHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"anchor_mail_id": mailID,
|
||||
"dir": dir,
|
||||
"nodes": nodes,
|
||||
"total": len(nodes),
|
||||
"hidden": len(raw) - len(nodes),
|
||||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||||
"has_more_up": hasMoreUp,
|
||||
"has_more_down": hasMoreDn,
|
||||
"next_up": upOffset + upLimit,
|
||||
"next_down": downOffset + downLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// intQuery 读取整数 query 参数并夹到 [min, max]。
|
||||
// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。
|
||||
func intQuery(r *http.Request, key string, def, min, max int) int {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
26
gateway/internal/handler/thread_test.go
Normal file
26
gateway/internal/handler/thread_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntQueryClampsAndFallsBack(t *testing.T) {
|
||||
cases := []struct {
|
||||
q string
|
||||
want int
|
||||
}{
|
||||
{"", 40}, // 缺省
|
||||
{"limit=10", 10}, // 正常
|
||||
{"limit=0", 1}, // 低于下限 → 夹到下限
|
||||
{"limit=999", 200}, // 高于上限 → 夹到上限
|
||||
{"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400
|
||||
{"limit=-5", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest("GET", "/x?"+c.q, nil)
|
||||
if got := intQuery(r, "limit", 40, 1, 200); got != c.want {
|
||||
t.Fatalf("intQuery(%q) = %d,期望 %d", c.q, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
gateway/internal/middleware/auth.go
Normal file
98
gateway/internal/middleware/auth.go
Normal file
@ -0,0 +1,98 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const AgentNameKey contextKey = "agent_name"
|
||||
|
||||
// bearerToken 从 Authorization: Bearer <token> 取出令牌,缺失时返回空串。
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。
|
||||
func BearerToken(r *http.Request) string { return bearerToken(r) }
|
||||
|
||||
// keyAuthError 把密钥校验错误翻译成对外文案。
|
||||
// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了,
|
||||
// 运维需要据此判断该重新签发还是该检查配置。
|
||||
func keyAuthError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
return `{"error":"密钥已使用(一次性密钥只能用一次)"}`
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
return `{"error":"密钥已过期"}`
|
||||
default:
|
||||
return `{"error":"密钥无效"}`
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAuth 验证 Agent 身份,支持两种凭证:
|
||||
//
|
||||
// Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)
|
||||
// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留)
|
||||
//
|
||||
// 用户密钥(user_keys)不接受:两类密钥共享 token 命名空间但走各自的验证表,
|
||||
// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。
|
||||
func AgentAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
agentName, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, keyAuthError(err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if agentName == "" {
|
||||
// 密钥有效但尚未绑定 Agent:注册接口会用请求里的 name 落定它,
|
||||
// 其余接口无法确定调用者身份,只能拒。
|
||||
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
repo.HeartbeatAgent(r.Context(), agentName)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
agentName := r.Header.Get("X-Agent-Name")
|
||||
agentSecret := r.Header.Get("X-Agent-Secret")
|
||||
if agentName == "" || agentSecret == "" {
|
||||
http.Error(w, `{"error":"Missing Authorization: Bearer <key> or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
repo.HeartbeatAgent(r.Context(), agent.Name)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentName 从 context 中获取 agent_name
|
||||
func GetAgentName(r *http.Request) string {
|
||||
if v := r.Context().Value(AgentNameKey); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
161
gateway/internal/middleware/user.go
Normal file
161
gateway/internal/middleware/user.go
Normal file
@ -0,0 +1,161 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
const UserKey contextKey = "auth_user"
|
||||
|
||||
// SetSessionCookie 写入登录 Cookie
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: config.C.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: config.C.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie 清除登录 Cookie
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
SetSessionCookie(w, "", -1)
|
||||
}
|
||||
|
||||
// SessionToken 从请求中取出登录令牌
|
||||
func SessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(config.C.CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// QueryToken 从 ?access_token= 取出令牌。
|
||||
//
|
||||
// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时
|
||||
// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志,
|
||||
// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。
|
||||
func QueryToken(r *http.Request) string {
|
||||
return strings.TrimSpace(r.URL.Query().Get("access_token"))
|
||||
}
|
||||
|
||||
// UserAuth 校验人类用户登录态,把 *models.User 注入 context。
|
||||
// 支持两种凭证:浏览器 Cookie,或 Authorization: Bearer <user_key_token>(第三方客户端)。
|
||||
func UserAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
// 只有 Cookie 路径才清 Cookie;密钥认证失败不应频带浏览器会话
|
||||
if bearerToken(r) == "" {
|
||||
ClearSessionCookie(w)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 叠在 UserAuth 之后,要求 role = admin
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := GetUser(r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"admin only"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。
|
||||
//
|
||||
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
|
||||
// URL 里的令牌会进访问日志与 Referer,所以不能全局开启。
|
||||
func UserAuthAllowQueryToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
if token := QueryToken(r); token != "" {
|
||||
if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil {
|
||||
u, err = ku, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// GetUser 从 context 取登录用户;未登录返回 nil
|
||||
func GetUser(r *http.Request) *models.User {
|
||||
if v := r.Context().Value(UserKey); v != nil {
|
||||
if u, ok := v.(*models.User); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserName 便捷取登录用户名
|
||||
func GetUserName(r *http.Request) string {
|
||||
if u := GetUser(r); u != nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OptionalUser 解析登录态但不拦截(SSE 等需要区分匿名/登录的场景)
|
||||
func OptionalUser(r *http.Request) *models.User {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。
|
||||
// 仅 SSE 用:浏览器 EventSource 无法带自定义头。
|
||||
func OptionalUserWithQuery(r *http.Request) *models.User {
|
||||
if u := OptionalUser(r); u != nil {
|
||||
return u
|
||||
}
|
||||
if token := QueryToken(r); token != "" {
|
||||
if u, err := repo.VerifyUserKey(r.Context(), token); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 解析调用者身份:Cookie 优先,其次 Bearer 用户密钥。
|
||||
//
|
||||
// 用户密钥只能走到这里(/me/* 与会话级接口),Agent 密钥只能走 AgentAuth,
|
||||
// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。
|
||||
func resolve(r *http.Request) (*models.User, error) {
|
||||
if token := SessionToken(r); token != "" {
|
||||
return repo.ResolveUserSession(r.Context(), token)
|
||||
}
|
||||
if token := bearerToken(r); token != "" {
|
||||
return repo.VerifyUserKey(r.Context(), token)
|
||||
}
|
||||
return nil, repo.ErrSessionInvalid
|
||||
}
|
||||
138
gateway/internal/models/address.go
Normal file
138
gateway/internal/models/address.go
Normal file
@ -0,0 +1,138 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address 是三维寻址 name@path.session 的解析结果
|
||||
type Address struct {
|
||||
Name string `json:"name"` // Agent 实例名(或人类用户名)
|
||||
Path string `json:"path"` // 工作区路径(可含 /,可为空)
|
||||
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
|
||||
Raw string `json:"raw"` // 原始字符串
|
||||
}
|
||||
|
||||
// SessionMode 是 session 位的三种语义
|
||||
type SessionMode int
|
||||
|
||||
const (
|
||||
// SessionDefault:session 位省略 → 投递到 name@path 的默认会话(不存在则建立)
|
||||
SessionDefault SessionMode = iota
|
||||
// SessionNew:session 位为 new → 强制新建一个会话
|
||||
SessionNew
|
||||
// SessionNamed:session 位为具体别名 → 必须已存在,否则无法送达
|
||||
SessionNamed
|
||||
)
|
||||
|
||||
// Mode 返回该地址 session 位的语义
|
||||
func (a Address) Mode() SessionMode {
|
||||
switch a.Session {
|
||||
case "":
|
||||
return SessionDefault
|
||||
case "new":
|
||||
return SessionNew
|
||||
default:
|
||||
return SessionNamed
|
||||
}
|
||||
}
|
||||
|
||||
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
|
||||
// 注意:session 位省略不等于 new,那是「默认会话」,见 Mode()。
|
||||
func (a Address) IsNewSession() bool {
|
||||
return a.Mode() == SessionNew
|
||||
}
|
||||
|
||||
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
|
||||
func (a Address) IsDefaultSession() bool {
|
||||
return a.Mode() == SessionDefault
|
||||
}
|
||||
|
||||
func (a Address) String() string {
|
||||
return a.Raw
|
||||
}
|
||||
|
||||
// ParseAddress 解析 name@path.session 三维地址。
|
||||
//
|
||||
// 支持形态:
|
||||
//
|
||||
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
|
||||
// pi@root.new → name=pi path=root session=new(新建)
|
||||
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
|
||||
// human@.new → name=human path="" session=new
|
||||
// human → name=human path="" session=""(默认会话)
|
||||
//
|
||||
// 规则:
|
||||
// - 第一个 @ 之前是 name(必填)
|
||||
// - @ 之后按【最后一个 .】切成 path 与 session,因此 path 内可以包含 . 与 /
|
||||
// - 没有 . 时,整段视为 path,session 为空(默认会话)
|
||||
//
|
||||
// session 位三态语义见 Address.Mode():省略=默认会话,new=新建,其他=必须已存在。
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return Address{}, fmt.Errorf("empty address")
|
||||
}
|
||||
|
||||
// 只有形如 "@name@path.session" 时才剥掉前导 @;
|
||||
// "@ModelRouter.new" 缺少 name,应当报错而不是被当成名字。
|
||||
trimmed := raw
|
||||
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
|
||||
trimmed = raw[1:]
|
||||
}
|
||||
|
||||
at := strings.Index(trimmed, "@")
|
||||
if at < 0 {
|
||||
// 只有名字:human / builder
|
||||
name := strings.TrimSpace(trimmed)
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
return Address{Name: name, Raw: raw}, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(trimmed[:at])
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
|
||||
rest := trimmed[at+1:]
|
||||
|
||||
// 按最后一个 . 切 path / session;path 内允许 / 与 .
|
||||
var path, session string
|
||||
if dot := strings.LastIndex(rest, "."); dot >= 0 {
|
||||
path = rest[:dot]
|
||||
session = rest[dot+1:]
|
||||
} else {
|
||||
path = rest
|
||||
}
|
||||
|
||||
return Address{
|
||||
Name: name,
|
||||
Path: strings.TrimSpace(path),
|
||||
Session: strings.TrimSpace(session),
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC)
|
||||
func ParseAddressList(s string) ([]Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
|
||||
out := make([]Address, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
addr, err := ParseAddress(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
94
gateway/internal/models/address_test.go
Normal file
94
gateway/internal/models/address_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
path string
|
||||
session string
|
||||
mode SessionMode
|
||||
}{
|
||||
// 用户给出的两个例子
|
||||
{"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed},
|
||||
{"pi@root.new", "pi", "root", "new", SessionNew},
|
||||
|
||||
// 常规形态
|
||||
{"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed},
|
||||
{"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew},
|
||||
{"human@.new", "human", "", "new", SessionNew},
|
||||
|
||||
// 省略 session 位 = 默认会话(不等于 new)
|
||||
{"human", "human", "", "", SessionDefault},
|
||||
{"ops@prod", "ops", "prod", "", SessionDefault},
|
||||
|
||||
// path 内含 . 与 /(按最后一个 . 切)
|
||||
{"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := ParseAddress(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if got.Name != c.name || got.Path != c.path || got.Session != c.session {
|
||||
t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}",
|
||||
c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session)
|
||||
}
|
||||
if got.Mode() != c.mode {
|
||||
t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session 位省略与 new 必须是两种不同语义:
|
||||
// 省略 → 默认会话;new → 强制新建;其他 → 必须已存在。
|
||||
func TestSessionModeSemantics(t *testing.T) {
|
||||
def, _ := ParseAddress("pi@root")
|
||||
if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() {
|
||||
t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession())
|
||||
}
|
||||
|
||||
new_, _ := ParseAddress("pi@root.new")
|
||||
if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() {
|
||||
t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode())
|
||||
}
|
||||
|
||||
named, _ := ParseAddress("pi@root.fix-leak")
|
||||
if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() {
|
||||
t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressErrors(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "@", "@ModelRouter.new"} {
|
||||
if _, err := ParseAddress(in); err == nil {
|
||||
t.Errorf("ParseAddress(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressList(t *testing.T) {
|
||||
list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("got %d addresses, want 3", len(list))
|
||||
}
|
||||
if list[0].Name != "pi" || !list[0].IsNewSession() {
|
||||
t.Errorf("addr[0] = %+v", list[0])
|
||||
}
|
||||
if list[1].Path != "/program" || list[1].Session != "upadtefeature" {
|
||||
t.Errorf("addr[1] = %+v", list[1])
|
||||
}
|
||||
if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault {
|
||||
t.Errorf("addr[2] = %+v", list[2])
|
||||
}
|
||||
|
||||
empty, err := ParseAddressList(" ")
|
||||
if err != nil || empty != nil {
|
||||
t.Errorf("empty list = %v, %v; want nil, nil", empty, err)
|
||||
}
|
||||
}
|
||||
242
gateway/internal/models/models.go
Normal file
242
gateway/internal/models/models.go
Normal file
@ -0,0 +1,242 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 代表一个已注册的 Agent 实例
|
||||
type Agent struct {
|
||||
ID uuid.UUID `json:"agent_id"`
|
||||
Name string `json:"agent_name"`
|
||||
Secret string `json:"-"`
|
||||
HostURL string `json:"host_url"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Workspace 是 Agent 管理的项目工作区
|
||||
type Workspace struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Session 是有明确边界的任务会话
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"session_id"`
|
||||
Alias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count,omitempty"`
|
||||
|
||||
// RenameDismissed 是用户驳回过的改名提议。
|
||||
// 记下来才能让提示条不再反复弹同一个建议。
|
||||
RenameDismissed string `json:"rename_dismissed,omitempty"`
|
||||
|
||||
// AliasSource 记录别名是谁定的:
|
||||
// platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
|
||||
AliasSource string `json:"alias_source,omitempty"`
|
||||
|
||||
// MaxRounds/UsedRounds 是本任务的往返预算(0 = 本会话不限)。
|
||||
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
|
||||
// 所以在写信时给、在对话页里随时调。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
}
|
||||
|
||||
// User 是人类用户(多用户账号体系)
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"` // admin / user
|
||||
Status string `json:"status"` // active / disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
|
||||
// 权限边界:空切片 = 不限
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// IsAdmin 判断是否管理员
|
||||
func (u User) IsAdmin() bool { return u.Role == "admin" }
|
||||
|
||||
// CanUseAgent 判断用户是否可向指定 Agent 发信
|
||||
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
|
||||
func (u User) CanUseAgent(agentName string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range u.AllowedAgents {
|
||||
if a == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUsePath 判断用户是否可访问指定工作区。
|
||||
// 空白名单 = 不限;管理员不受限;空 path(人类地址)总是允许。
|
||||
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub)。
|
||||
func (u User) CanUsePath(path string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range u.AllowedPaths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
prefix := p
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mail 是会话中的一封邮件
|
||||
type Mail struct {
|
||||
ID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParentMailID *uuid.UUID `json:"parent_mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
FromWorkspace string `json:"from_workspace"`
|
||||
ToName string `json:"to_name"`
|
||||
ToWorkspace string `json:"to_workspace"`
|
||||
CCList []Address `json:"cc_list"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
MailType string `json:"mail_type"`
|
||||
PermOptions []string `json:"permission_options,omitempty"`
|
||||
PermResult string `json:"permission_result,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HopLimit int `json:"hop_limit"`
|
||||
|
||||
SessionAlias string `json:"session_alias,omitempty"`
|
||||
BodyPreview string `json:"body_preview,omitempty"`
|
||||
|
||||
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
|
||||
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
|
||||
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
// 「谁在哪一封里提了什么」应当留痕。
|
||||
RenameAlias string `json:"rename_alias,omitempty"`
|
||||
RenameReason string `json:"rename_reason,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionRequest 是 Agent 向人类发起的权限请求
|
||||
type PermissionRequest struct {
|
||||
ID uuid.UUID `json:"request_id"`
|
||||
MailID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
Result *string `json:"result"`
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SSE 事件类型
|
||||
const (
|
||||
EventNewMail = "new_mail"
|
||||
EventPermissionDecision = "permission_decision"
|
||||
EventSessionUpdate = "session_update"
|
||||
EventAgentOnline = "agent_online"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
|
||||
// 密钥类型:签发时决定其生命周期
|
||||
const (
|
||||
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
|
||||
KeyPermanent = "permanent"
|
||||
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
|
||||
KeyOneTime = "one_time"
|
||||
// KeyTimed 到 ExpiresAt 之后失效
|
||||
KeyTimed = "timed"
|
||||
)
|
||||
|
||||
// ValidKeyType 判断密钥类型是否受支持
|
||||
func ValidKeyType(t string) bool {
|
||||
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
|
||||
}
|
||||
|
||||
// AgentKey 是管理员签发的 Agent 接入密钥。
|
||||
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent,
|
||||
// 首次注册时由注册请求里的 name 落定。
|
||||
type AgentKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
|
||||
AgentName *string `json:"agent_name"`
|
||||
KeyType string `json:"key_type"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
|
||||
type UserKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TokenHint 返回密钥的展示形式:只露前 8 位。
|
||||
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
|
||||
func TokenHint(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8] + "…"
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
|
||||
//
|
||||
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
|
||||
//(Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。
|
||||
type Attachment struct {
|
||||
ID uuid.UUID `json:"attachment_id"`
|
||||
MailID *uuid.UUID `json:"mail_id"`
|
||||
Uploader string `json:"uploader"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
226
gateway/internal/repo/attachments.go
Normal file
226
gateway/internal/repo/attachments.go
Normal file
@ -0,0 +1,226 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 元数据在库、内容在磁盘(internal/blob)。两者的一致性由调用顺序保证:
|
||||
// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。
|
||||
// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。
|
||||
|
||||
var (
|
||||
// ErrAttachmentNotFound 附件不存在
|
||||
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
// ErrAttachmentNotOwned 附件不属于该上传者
|
||||
ErrAttachmentNotOwned = errors.New("attachment not owned by uploader")
|
||||
// ErrAttachmentAlreadyAttached 附件已挂到别的邮件上
|
||||
ErrAttachmentAlreadyAttached = errors.New("attachment already attached")
|
||||
)
|
||||
|
||||
const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at`
|
||||
|
||||
func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename,
|
||||
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateAttachment 登记一条待挂载的附件(mail_id 为空)。
|
||||
func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) {
|
||||
a := &models.Attachment{
|
||||
Uploader: uploader,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
SHA256: sum,
|
||||
}
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING attachment_id, created_at
|
||||
`, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAttachment 读取一条附件元数据。
|
||||
func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) {
|
||||
a, err := scanAttachment(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// ListAttachmentsFor 列出某封邮件的附件。
|
||||
func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Attachment{}
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AttachToMail 把一批待挂载附件绑到某封邮件上。
|
||||
//
|
||||
// 每条都要求:存在、属于该上传者、且尚未挂载。
|
||||
// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入,
|
||||
// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。
|
||||
func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE attachments SET mail_id = $1
|
||||
WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL
|
||||
`, mailID, id, uploader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 没改到:查明原因,给调用方一个能照着修的错误
|
||||
a, gErr := GetAttachment(ctx, id)
|
||||
if gErr != nil {
|
||||
return gErr
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。
|
||||
//
|
||||
// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。
|
||||
// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定,
|
||||
// 而不是沿用原上传者。返回复制的数量。
|
||||
func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) {
|
||||
src, err := ListAttachmentsFor(ctx, srcMailID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, a := range src {
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(src), nil
|
||||
}
|
||||
|
||||
// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。
|
||||
// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。
|
||||
func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if _, err = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var refs int
|
||||
if err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return a.SHA256, refs == 0, nil
|
||||
}
|
||||
|
||||
// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录,
|
||||
// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。
|
||||
//
|
||||
// 上传后没走完发信流程(用户取消、Agent 崩溃)会留下这类记录,
|
||||
// 不清理的话磁盘只会单调增长。
|
||||
func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) {
|
||||
cutoff := time.Now().Add(-age)
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT attachment_id, sha256 FROM attachments
|
||||
WHERE mail_id IS NULL AND created_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type orphan struct {
|
||||
id uuid.UUID
|
||||
sum string
|
||||
}
|
||||
var found []orphan
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
if err := rows.Scan(&o.id, &o.sum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, o)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var removable []string
|
||||
for _, o := range found {
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refs == 0 {
|
||||
removable = append(removable, o.sum)
|
||||
}
|
||||
}
|
||||
return removable, nil
|
||||
}
|
||||
|
||||
// AttachmentAccessible 判断某人是否有权读取某附件:
|
||||
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
|
||||
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
|
||||
if a.MailID == nil {
|
||||
return a.Uploader == name, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM mails m
|
||||
WHERE m.mail_id = $1
|
||||
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
`, *a.MailID, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
165
gateway/internal/repo/budget_test.go
Normal file
165
gateway/internal/repo/budget_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// setupBudgetDB 复用 quota_test.go 的临时库,再建一个会话。
|
||||
// 用真实 SQLite 而非 mock:预算的正确性核心是「判断与自增在同一条 UPDATE 里」,
|
||||
// 那正是只有真实数据库才能验证的部分。
|
||||
func setupBudgetDB(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
setupTestDB(t)
|
||||
id, err := CreateSession(context.Background(), nil, "bot", "预算测试")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSessionBudgetZeroMeansUnlimited(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认 0 = 不限:引入预算不该把已在进行的会话卡死
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !b.Unlimited || b.Remaining != -1 {
|
||||
t.Fatalf("默认应为不限:%+v", b)
|
||||
}
|
||||
// 不限时反复占用都成功
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err != nil {
|
||||
t.Fatalf("不限额下第 %d 次占用失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetExhausts(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := SetSessionBudget(ctx, id, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次应成功: %v", i, err)
|
||||
}
|
||||
if b.Used != i {
|
||||
t.Fatalf("第 %d 次后 used = %d", i, b.Used)
|
||||
}
|
||||
}
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatalf("第 3 次应耗尽,得到 err=%v b=%+v", err, b)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("耗尽后剩余应为 0:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 判断与自增必须在同一条 UPDATE 里,否则并发下会把预算刷穿
|
||||
func TestSessionBudgetConcurrentDoesNotOverdraw(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
const limit = 10
|
||||
if _, err := SetSessionBudget(ctx, id, limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
ok := 0
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err == nil {
|
||||
mu.Lock()
|
||||
ok++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ok != limit {
|
||||
t.Fatalf("40 并发下成功 %d 次,期望恰好 %d 次(预算被刷穿或误拒)", ok, limit)
|
||||
}
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != limit {
|
||||
t.Fatalf("used = %d,期望 %d", b.Used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetResetAndLowerBelowUsed(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
SetSessionBudget(ctx, id, 5)
|
||||
for i := 0; i < 3; i++ {
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// 调到低于已用次数 = 「就到这里为止」,是人的合法意图,不该报错
|
||||
b, err := SetSessionBudget(ctx, id, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("下调预算不该失败: %v", err)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("已用 3 上限 1 时剩余应为 0:%+v", b)
|
||||
}
|
||||
if _, err := ConsumeSessionBudget(ctx, id); !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatal("下调后应立即拦住")
|
||||
}
|
||||
|
||||
// 重置只清已用次数,不动上限
|
||||
b, err = ResetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Used != 0 || b.Max != 1 {
|
||||
t.Fatalf("重置后应为 0/1:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话预算先扣、全局配额后扣;全局拦下时必须把会话那次退回去,
|
||||
// 否则那格白掉了 —— 那次往返实际上没有发生
|
||||
func TestRefundSessionBudget(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
SetSessionBudget(ctx, id, 3)
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("退还后 used 应为 0:%+v", b)
|
||||
}
|
||||
|
||||
// 已经是 0 时再退不该变成负数
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ = GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("重复退还把 used 变成了 %d", b.Used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionBudgetMissingSession(t *testing.T) {
|
||||
setupBudgetDB(t)
|
||||
if _, err := GetSessionBudget(context.Background(), uuid.New()); err == nil {
|
||||
t.Fatal("不存在的会话应报错")
|
||||
} else if errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal("应包装成可读错误而不是裸 sql.ErrNoRows")
|
||||
}
|
||||
}
|
||||
341
gateway/internal/repo/keys.go
Normal file
341
gateway/internal/repo/keys.go
Normal file
@ -0,0 +1,341 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
//
|
||||
// 两类密钥共享一个全局唯一的 token 命名空间:验证时先查 agent_keys 再查 user_keys。
|
||||
// 这样一个 token 永远只有一种身份,不会出现「同一串字符既能注册 Agent 又能读人类邮箱」。
|
||||
|
||||
var (
|
||||
// ErrKeyNotFound 密钥不存在
|
||||
ErrKeyNotFound = errors.New("key not found")
|
||||
// ErrKeyUsed 一次性密钥已被使用
|
||||
ErrKeyUsed = errors.New("key already used")
|
||||
// ErrKeyExpired 定时密钥已过期
|
||||
ErrKeyExpired = errors.New("key expired")
|
||||
// ErrKeyTypeInvalid 密钥类型不受支持
|
||||
ErrKeyTypeInvalid = errors.New("invalid key type")
|
||||
// ErrKeyNeedsExpiry timed 密钥缺少有效的 expires_hours
|
||||
ErrKeyNeedsExpiry = errors.New("timed key requires positive expires_hours")
|
||||
// ErrKeyTooShort 登记的客户端密钥长度不足
|
||||
ErrKeyTooShort = errors.New("key token too short")
|
||||
)
|
||||
|
||||
// expiryFor 依据密钥类型算出过期时间。
|
||||
// 只有 timed 需要 expires_at;permanent 与 one_time 都是 NULL,
|
||||
// 各自的失效条件由 key_type 本身表达,不混用 expires_at。
|
||||
func expiryFor(keyType string, hours int) (*time.Time, error) {
|
||||
if !models.ValidKeyType(keyType) {
|
||||
return nil, ErrKeyTypeInvalid
|
||||
}
|
||||
if keyType != models.KeyTimed {
|
||||
return nil, nil
|
||||
}
|
||||
if hours <= 0 {
|
||||
return nil, ErrKeyNeedsExpiry
|
||||
}
|
||||
t := time.Now().Add(time.Duration(hours) * time.Hour)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// checkKeyUsable 判断一条密钥记录当前是否可用。
|
||||
func checkKeyUsable(keyType string, expiresAt, usedAt *time.Time) error {
|
||||
switch keyType {
|
||||
case models.KeyOneTime:
|
||||
if usedAt != nil {
|
||||
return ErrKeyUsed
|
||||
}
|
||||
case models.KeyTimed:
|
||||
if expiresAt == nil || time.Now().After(*expiresAt) {
|
||||
return ErrKeyExpired
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Agent 密钥(管理员签发) ----------
|
||||
|
||||
// ErrKeyTokenTaken 登记的密钥已被占用
|
||||
var ErrKeyTokenTaken = errors.New("key token already registered")
|
||||
|
||||
// CreateAgentKey 签发一条 Agent 接入密钥。agentName 为空表示待绑定。
|
||||
//
|
||||
// presetToken 非空时登记客户端已在本地生成的密钥(插件首装场景),
|
||||
// 这样密钥全文只从客户端往服务器走一次,不需要反方向传递;留空则由服务器生成。
|
||||
func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expiresHours int, createdBy uuid.UUID, presetToken string) (*models.AgentKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := presetToken
|
||||
if token == "" {
|
||||
if token, err = newToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if len(token) < 32 {
|
||||
// 太短的客户端密钥不接受,否则等于把弱口令当凭证
|
||||
return nil, ErrKeyTooShort
|
||||
}
|
||||
|
||||
var namePtr *string
|
||||
if agentName != "" {
|
||||
namePtr = &agentName
|
||||
}
|
||||
|
||||
k := &models.AgentKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
AgentName: namePtr,
|
||||
KeyType: keyType,
|
||||
Label: label,
|
||||
ExpiresAt: expires,
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO agent_keys (key_token, agent_name, key_type, label, expires_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING key_id, created_at
|
||||
`, token, namePtr, keyType, label, expires, createdBy).Scan(&k.ID, &k.CreatedAt)
|
||||
if db.IsUniqueViolation(err) {
|
||||
return nil, ErrKeyTokenTaken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListAgentKeys 列出 Agent 密钥;agentName 非空时按 Agent 过滤。
|
||||
// 返回值不含 token 全文,只有 hint。
|
||||
func ListAgentKeys(ctx context.Context, agentName string) ([]models.AgentKey, error) {
|
||||
q := `SELECT key_id, key_token, agent_name, key_type, label, expires_at, used_at, created_by, created_at
|
||||
FROM agent_keys`
|
||||
args := []any{}
|
||||
if agentName != "" {
|
||||
q += ` WHERE agent_name = $1`
|
||||
args = append(args, agentName)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.AgentKey{}
|
||||
for rows.Next() {
|
||||
var k models.AgentKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.AgentName, &k.KeyType, &k.Label,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedBy, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token) // 不回传全文
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAgentKey 吊销一条 Agent 密钥。
|
||||
func DeleteAgentKey(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx, `DELETE FROM agent_keys WHERE key_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindAgentKey 把一条密钥绑定到指定 Agent 名。
|
||||
func BindAgentKey(ctx context.Context, id uuid.UUID, agentName string) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_id = $1`, id, agentName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyAgentKey 校验 Agent 密钥并返回它绑定的 Agent 名(未绑定时返回空串)。
|
||||
//
|
||||
// 一次性密钥在校验通过时立刻写 used_at —— 用 WHERE used_at IS NULL 保证并发下
|
||||
// 只有一个请求能把它标记掉,避免两个 Agent 拿同一把一次性密钥同时注册成功。
|
||||
func VerifyAgentKey(ctx context.Context, token string) (string, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
agentName *string
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, agent_name, key_type, expires_at, used_at
|
||||
FROM agent_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &agentName, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return "", ErrKeyUsed // 并发下被别人抢先用掉了
|
||||
}
|
||||
}
|
||||
|
||||
if agentName == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *agentName, nil
|
||||
}
|
||||
|
||||
// ClaimAgentKey 在待绑定密钥首次注册时把它落定到该 Agent 名。
|
||||
// 已绑定的密钥不受影响(WHERE agent_name IS NULL),因此不能借一把已绑定的密钥改注册别的 Agent。
|
||||
func ClaimAgentKey(ctx context.Context, token, agentName string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_token = $1 AND agent_name IS NULL`,
|
||||
token, agentName)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 用户密钥(用户自助签发) ----------
|
||||
|
||||
// CreateUserKey 为用户签发一条客户端连接密钥。
|
||||
func CreateUserKey(ctx context.Context, userID uuid.UUID, label, keyType string, expiresHours int) (*models.UserKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &models.UserKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
KeyType: keyType,
|
||||
ExpiresAt: expires,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO user_keys (key_token, user_id, label, key_type, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING key_id, created_at
|
||||
`, token, userID, label, keyType, expires).Scan(&k.ID, &k.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListUserKeys 列出某用户的连接密钥(不含 token 全文)。
|
||||
func ListUserKeys(ctx context.Context, userID uuid.UUID) ([]models.UserKey, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT key_id, key_token, user_id, label, key_type, expires_at, used_at, created_at
|
||||
FROM user_keys WHERE user_id = $1 ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.UserKey{}
|
||||
for rows.Next() {
|
||||
var k models.UserKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.UserID, &k.Label, &k.KeyType,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUserKey 删除自己的一条密钥。带 user_id 条件,避免删掉别人的。
|
||||
func DeleteUserKey(ctx context.Context, userID, keyID uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM user_keys WHERE key_id = $1 AND user_id = $2`, keyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyUserKey 校验用户密钥并返回对应用户。
|
||||
// 用户必须仍处于 active 状态——禁用账号后其密钥应当立即失效。
|
||||
func VerifyUserKey(ctx context.Context, token string) (*models.User, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
userID uuid.UUID
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, user_id, key_type, expires_at, used_at
|
||||
FROM user_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &userID, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE user_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return nil, ErrKeyUsed
|
||||
}
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrKeyNotFound // 账号已禁用,密钥一并失效
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
282
gateway/internal/repo/quota.go
Normal file
282
gateway/internal/repo/quota.go
Normal file
@ -0,0 +1,282 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 配额 ----------
|
||||
//
|
||||
// 配额限制的是 Agent「主动发信」的次数,不限制收信:
|
||||
// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。
|
||||
//
|
||||
// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。
|
||||
|
||||
// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。
|
||||
var ErrQuotaExhausted = errors.New("quota exhausted")
|
||||
|
||||
// Quota 是一个 Agent 的配额快照。
|
||||
type Quota 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"`
|
||||
}
|
||||
|
||||
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 原子地占用一次发信配额,返回占用后的快照。
|
||||
//
|
||||
// 判断与自增必须在同一条 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// SetQuota 设置某 Agent 的配额上限(0 = 不限)。
|
||||
func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(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)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// ListQuotas 列出所有 Agent 的配额(管理员视图)。
|
||||
func ListQuotas(ctx context.Context) ([]Quota, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Quota{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var max, used int
|
||||
if err := rows.Scan(&name, &max, &used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, makeQuota(name, max, used))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 转发 ----------
|
||||
|
||||
// ForwardSource 是被转发邮件的必要信息。
|
||||
type ForwardSource struct {
|
||||
Mail *models.Mail
|
||||
Session uuid.UUID
|
||||
}
|
||||
|
||||
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
|
||||
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
|
||||
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
|
||||
m, err := GetMailByID(ctx, mailID)
|
||||
if err != nil {
|
||||
return nil, ErrMailNotFound
|
||||
}
|
||||
|
||||
if m.FromName == actor || m.ToName == actor {
|
||||
return m, nil
|
||||
}
|
||||
for _, cc := range m.CCList {
|
||||
if cc.Name == actor {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrForwardNotAllowed
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrMailNotFound 待转发的邮件不存在
|
||||
ErrMailNotFound = errors.New("mail not found")
|
||||
// ErrForwardNotAllowed 转发者未参与该邮件
|
||||
ErrForwardNotAllowed = errors.New("not a participant of that mail")
|
||||
)
|
||||
|
||||
// ---------- 会话级往返预算 ----------
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。
|
||||
// 只有 agents.max_rounds 一个全局计数器时有两个问题:
|
||||
// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉
|
||||
// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活
|
||||
// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
//
|
||||
// **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` 开一串会话
|
||||
// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。
|
||||
|
||||
// SessionBudget 是一个会话的往返预算快照。
|
||||
type SessionBudget struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
Max int `json:"max_rounds"` // 0 = 本会话不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget {
|
||||
b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if b.Unlimited {
|
||||
b.Remaining = -1
|
||||
return b
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
b.Remaining = r
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。
|
||||
var ErrSessionBudgetExhausted = errors.New("session budget exhausted")
|
||||
|
||||
// GetSessionBudget 读取会话预算。
|
||||
func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`,
|
||||
id).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
return makeSessionBudget(id, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeSessionBudget 原子地占用会话的一次往返。
|
||||
//
|
||||
// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把预算刷穿。
|
||||
func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1
|
||||
WHERE session_id = $1
|
||||
AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds)
|
||||
`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
b, bErr := GetSessionBudget(ctx, id)
|
||||
if bErr != nil {
|
||||
return SessionBudget{}, bErr
|
||||
}
|
||||
return b, ErrSessionBudgetExhausted
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionBudget 设置会话预算上限(0 = 不限)。
|
||||
//
|
||||
// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图,
|
||||
// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0,下次发信即被拦。
|
||||
func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// ResetSessionBudget 把该会话的已用次数归零(上限不变)。
|
||||
func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// RefundSessionBudget 退还一次往返。
|
||||
//
|
||||
// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去,
|
||||
// 否则会话预算白掉一格 —— 那次往返实际上没有发生。
|
||||
func RefundSessionBudget(ctx context.Context, id uuid.UUID) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1
|
||||
WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id)
|
||||
}
|
||||
173
gateway/internal/repo/quota_test.go
Normal file
173
gateway/internal/repo/quota_test.go
Normal file
@ -0,0 +1,173 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// setupTestDB 起一个临时 SQLite 库并建表,供配额测试使用。
|
||||
// 直接用真实的 SQLite 而非 mock:配额的正确性核心在于「判断与自增在同一条 UPDATE 里」,
|
||||
// 这正是只有真实数据库才能验证的部分。
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
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)`,
|
||||
name, max)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeQuotaCountsDown(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 3)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
q, err := ConsumeQuota(ctx, "bot")
|
||||
if !errors.Is(err, ErrQuotaExhausted) {
|
||||
t.Fatalf("第 4 次应耗尽,得到 err=%v", err)
|
||||
}
|
||||
// 耗尽时仍要给出快照,调用方才能在错误文案里写清 used/max
|
||||
if q.Used != 3 || q.Max != 3 {
|
||||
t.Errorf("耗尽时快照不对: %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeQuotaUnlimited(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "free", 0) // 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额的核心保证:并发发信不能把额度刷穿。
|
||||
// 判断与自增若分成两步(先 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")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Used != limit {
|
||||
t.Errorf("used_rounds = %d,应恰好等于上限 %d(未刷穿也未少记)", q.Used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndResetQuota(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 2)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := ConsumeQuota(ctx, "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q, err := SetQuota(ctx, "bot", 5)
|
||||
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)
|
||||
}
|
||||
|
||||
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 不该被报成「配额耗尽」")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
87
gateway/internal/repo/relay.go
Normal file
87
gateway/internal/repo/relay.go
Normal file
@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 插件自动转发(免配额通道) ----------
|
||||
//
|
||||
// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。**
|
||||
//
|
||||
// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列:
|
||||
// 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到,
|
||||
// Agent 卡在那里等一个永远不会来的回答
|
||||
// 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了,
|
||||
// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了
|
||||
//
|
||||
// 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识
|
||||
// (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次,
|
||||
// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id ——
|
||||
// 而那些 id 由平台生成,模型伪造不出来。
|
||||
|
||||
// ErrRelayDuplicate 表示这条上游消息已经转发过了。
|
||||
var ErrRelayDuplicate = errors.New("relay already recorded")
|
||||
|
||||
// ClaimRelay 占用一次免配额转发名额。
|
||||
//
|
||||
// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 ——
|
||||
// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。
|
||||
//
|
||||
// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错:
|
||||
// 重复转发是插件重试的正常结果,不是故障。
|
||||
func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`,
|
||||
agentName, relayKey, kind)
|
||||
if err != nil {
|
||||
if db.IsUniqueViolation(err) {
|
||||
return ErrRelayDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计
|
||||
// 「这封免配额的信是从哪条上游消息来的」。
|
||||
//
|
||||
// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。
|
||||
func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`,
|
||||
mailID, agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseRelay 撤销名额占用。
|
||||
//
|
||||
// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 ——
|
||||
// 幂等键会一直认为它已经转过。
|
||||
func ReleaseRelay(ctx context.Context, agentName, relayKey string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`,
|
||||
agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// RelayKeyForMail 反查某封邮件对应的上游消息 id。
|
||||
//
|
||||
// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission ——
|
||||
// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。
|
||||
// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。
|
||||
//
|
||||
// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。
|
||||
func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) {
|
||||
var key, kind string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`,
|
||||
mailID).Scan(&key, &kind)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return key, kind
|
||||
}
|
||||
1046
gateway/internal/repo/repo.go
Normal file
1046
gateway/internal/repo/repo.go
Normal file
File diff suppressed because it is too large
Load Diff
188
gateway/internal/repo/thread.go
Normal file
188
gateway/internal/repo/thread.go
Normal file
@ -0,0 +1,188 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 对话树。
|
||||
//
|
||||
// **不另建 tree_nodes 表**:`mails.parent_mail_id` 已经完整编码了树结构 ——
|
||||
// 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相,
|
||||
// 两处不一致时无法判断谁对。这里直接用递归 CTE 在 mails 上查。
|
||||
//
|
||||
// 树可以跨会话:转发把线索引到新会话,但 parent 仍指向原件。这正是「对话树」比
|
||||
// 「会话内平铺」更有价值的地方 —— 能看出一条线索分叉去了哪里。
|
||||
// 也正因如此,读取时必须按会话逐个鉴权(见 handler):
|
||||
// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
|
||||
//
|
||||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
// 按方向分页 —— 祖先向上、子孙向下,各自带游标。
|
||||
//
|
||||
// 游标用「相对锚点的原始层号偏移」而不是 mail_id:
|
||||
// - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明
|
||||
// 「这个 cursor 真的在这条线索上」
|
||||
// - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去),
|
||||
// 那就得单独校验它确实是锚点的祖先,反而更绕
|
||||
// - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间
|
||||
|
||||
// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB,
|
||||
// 前端点开某封时再单取全文与附件清单。
|
||||
type TreeMail struct {
|
||||
models.Mail
|
||||
// Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,1 = 子。
|
||||
// 不用「距根深度」—— 分块加载时根可能还没取到,绝对深度无从得知。
|
||||
Depth int `json:"depth"`
|
||||
AttachmentCount int `json:"attachment_count"`
|
||||
}
|
||||
|
||||
// descendantDepthCap 只是数据损坏时的兜底。
|
||||
//
|
||||
// parent_mail_id 正常不成环(新邮件只能指向已存在的旧邮件),但一旦被外部工具改坏,
|
||||
// 无上限的递归 CTE 会把进程拖死。取得足够大,正常数据碰不到。
|
||||
const descendantDepthCap = 10000
|
||||
|
||||
const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
|
||||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。
|
||||
//
|
||||
// **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话,
|
||||
// 再往上却可能仍是自己参与的往来。过滤放在 handler 层(那里知道调用者是谁)。
|
||||
//
|
||||
// 第二个返回值表示 offset+limit 层之上还有节点。
|
||||
func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||||
WHERE up.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, u.lvl
|
||||
FROM up u
|
||||
JOIN mails m ON m.mail_id = u.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE u.lvl > $3
|
||||
ORDER BY u.lvl ASC
|
||||
`, anchorID, offset+limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// 多取一层用来判断「上面还有没有」,不返回给调用方
|
||||
out, err := scanTreeRows(rows, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// DescendantsRaw 取锚点及其子孙,BFS 顺序(同层按时间),按节点数分页。
|
||||
//
|
||||
// offset = 0 时结果的第一个是锚点自己(Depth 0)。
|
||||
// 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点
|
||||
// (别人把线索转走又转回来给我)。
|
||||
//
|
||||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一封邮件的子孙通常很少
|
||||
// (分支来自转发,不是回复),这个代价可以接受;真出现巨型子树时再加物化。
|
||||
func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE down(mail_id, lvl) AS (
|
||||
SELECT mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, down.lvl + 1
|
||||
FROM mails m JOIN down ON m.parent_mail_id = down.mail_id
|
||||
WHERE down.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, d.lvl
|
||||
FROM down d
|
||||
JOIN mails m ON m.mail_id = d.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
ORDER BY d.lvl ASC, m.created_at ASC, m.mail_id ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, anchorID, descendantDepthCap, limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out, err := scanTreeRows(rows, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。
|
||||
func scanTreeRows(rows interface {
|
||||
Next() bool
|
||||
Scan(...interface{}) error
|
||||
Err() error
|
||||
Close() error
|
||||
}, negate bool) ([]TreeMail, error) {
|
||||
defer rows.Close()
|
||||
|
||||
out := []TreeMail{}
|
||||
for rows.Next() {
|
||||
var t TreeMail
|
||||
var alias *string
|
||||
var ccJSON []byte
|
||||
var lvl int
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.AttachmentCount, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
json.Unmarshal(ccJSON, &t.CCList)
|
||||
}
|
||||
if t.CCList == nil {
|
||||
t.CCList = []models.Address{}
|
||||
}
|
||||
if alias != nil {
|
||||
t.SessionAlias = *alias
|
||||
}
|
||||
if negate {
|
||||
t.Depth = -lvl
|
||||
} else {
|
||||
t.Depth = lvl
|
||||
}
|
||||
t.BodyPreview = preview(t.Body, 240)
|
||||
t.Body = "" // 树视图只要预览,全文按需单取
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断正文。
|
||||
// 直接切字节会把多字节字符切成半个,前端渲染出 U+FFFD 替换符。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8Start(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// utf8Start 判断某字节是否为一个 UTF-8 序列的首字节
|
||||
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }
|
||||
23
gateway/internal/repo/thread_test.go
Normal file
23
gateway/internal/repo/thread_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
|
||||
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
|
||||
s := "巡检报告"
|
||||
got := preview(s, 4)
|
||||
if got != "巡..." {
|
||||
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
|
||||
}
|
||||
for i, r := range got {
|
||||
if r == 0xFFFD {
|
||||
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
|
||||
if got := preview("短正文", 240); got != "短正文" {
|
||||
t.Fatalf("未超长却被改动:%q", got)
|
||||
}
|
||||
}
|
||||
512
gateway/internal/repo/users.go
Normal file
512
gateway/internal/repo/users.go
Normal file
@ -0,0 +1,512 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
bcryptCost = 12
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrBadCredentials = errors.New("invalid username or password")
|
||||
ErrUserDisabled = errors.New("user disabled")
|
||||
ErrNameTaken = errors.New("name already taken by an agent or user")
|
||||
ErrSessionInvalid = errors.New("session invalid or expired")
|
||||
ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]")
|
||||
ErrAlreadySetup = errors.New("system already initialized")
|
||||
)
|
||||
|
||||
// ---------- 命名空间校验 ----------
|
||||
|
||||
// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一
|
||||
func nameTaken(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT (SELECT COUNT(*) FROM users WHERE username = $1)
|
||||
+ (SELECT COUNT(*) FROM agents WHERE agent_name = $1)
|
||||
`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名)
|
||||
func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
func validUsername(name string) bool {
|
||||
if len(name) < 2 || len(name) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 保留字:human 是兼容别名,不能被真实用户占用
|
||||
return name != "human"
|
||||
}
|
||||
|
||||
// ---------- User CRUD ----------
|
||||
|
||||
func scanUser(row *sql.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func decodeStrList(raw []byte) []string {
|
||||
out := []string{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CreateUser(ctx context.Context, username, password, displayName, role string,
|
||||
allowedAgents, allowedPaths []string) (*models.User, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if !validUsername(username) {
|
||||
return nil, ErrInvalidUsername
|
||||
}
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
taken, err := nameTaken(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentsJSON, _ := json.Marshal(normalizeList(allowedAgents))
|
||||
pathsJSON, _ := json.Marshal(normalizeList(allowedPaths))
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+userSelectCols,
|
||||
username, displayName, string(hash), role, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func GetUserByName(ctx context.Context, username string) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE username = $1`,
|
||||
strings.ToLower(strings.TrimSpace(username))))
|
||||
}
|
||||
|
||||
func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id))
|
||||
}
|
||||
|
||||
func ListUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []models.User{}
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UserUpdate 描述一次用户更新;nil 字段表示不改
|
||||
type UserUpdate struct {
|
||||
DisplayName *string
|
||||
Role *string
|
||||
Status *string
|
||||
AllowedAgents *[]string
|
||||
AllowedPaths *[]string
|
||||
}
|
||||
|
||||
func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) {
|
||||
var agentsJSON, pathsJSON *string
|
||||
if up.AllowedAgents != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedAgents))
|
||||
s := string(b)
|
||||
agentsJSON = &s
|
||||
}
|
||||
if up.AllowedPaths != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedPaths))
|
||||
s := string(b)
|
||||
pathsJSON = &s
|
||||
}
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
UPDATE users SET
|
||||
display_name = COALESCE($2, display_name),
|
||||
role = COALESCE($3, role),
|
||||
status = COALESCE($4, status),
|
||||
allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents),
|
||||
allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths)
|
||||
WHERE user_id = $1
|
||||
RETURNING `+userSelectCols,
|
||||
id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// normalizeList 去空白、去空项、去重,保持顺序
|
||||
func normalizeList(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
// 改密后踢掉该用户所有会话
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableUser(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET status = 'disabled' WHERE user_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EnsureAdminUser 首次启动时创建默认管理员(幂等)
|
||||
func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) {
|
||||
if n, err := CountAdmins(ctx); err != nil {
|
||||
return nil, false, err
|
||||
} else if n > 0 {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil && !errors.Is(err, ErrUserNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, false, nil
|
||||
}
|
||||
u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
// ---------- 登录 / 会话令牌 ----------
|
||||
|
||||
func Authenticate(ctx context.Context, username, password string) (*models.User, error) {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// 统一错误,避免暴露用户是否存在
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) {
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expires := time.Now().Add(sessionTTL)
|
||||
if len(userAgent) > 256 {
|
||||
userAgent = userAgent[:256]
|
||||
}
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO user_sessions (token, user_id, expires_at, user_agent)
|
||||
VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
// 顺手清理过期令牌
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`)
|
||||
return token, expires, nil
|
||||
}
|
||||
|
||||
// ResolveUserSession 校验令牌并滑动续期
|
||||
func ResolveUserSession(ctx context.Context, token string) (*models.User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
var userID uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`,
|
||||
token, time.Now().Add(sessionTTL))
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func DeleteUserSession(ctx context.Context, token string) error {
|
||||
_, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 人类用户候选(供地址补全) ----------
|
||||
|
||||
func ListActiveUsernames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT username FROM users WHERE status = 'active' ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 会话归属 ----------
|
||||
|
||||
func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`,
|
||||
sessionID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串
|
||||
func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) {
|
||||
var name *string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT u.username
|
||||
FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id
|
||||
WHERE s.session_id = $1`, sessionID).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if name == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *name, nil
|
||||
}
|
||||
|
||||
// UserCanAccessSession 判断用户能否访问该会话:owner、或在邮件收发/抄送中出现,或 admin
|
||||
func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) {
|
||||
if u.IsAdmin() {
|
||||
return true, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.session_id = $1
|
||||
AND (s.owner_user_id = $2
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.from_name = $3 OR m.to_name = $3
|
||||
OR `+db.CCHas("m.cc_list", 3)+`)
|
||||
))
|
||||
`, sessionID, u.ID, u.Username).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
|
||||
func FirstAdminUsername(ctx context.Context) (string, error) {
|
||||
var name string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT username FROM users
|
||||
WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "ChangeMe" + fmt.Sprint(time.Now().Unix())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---------- Setup(首次初始化管理员) ----------
|
||||
|
||||
// NeedsSetup 返回系统是否尚未初始化(没有任何用户)
|
||||
func NeedsSetup(ctx context.Context) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。
|
||||
// 已初始化时返回 ErrAlreadySetup,避免被用作后门。
|
||||
func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) {
|
||||
empty, err := NeedsSetup(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !empty {
|
||||
return nil, ErrAlreadySetup
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
return CreateUser(ctx, username, password, displayName, "admin", nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 可选目录候选(供权限设置界面) ----------
|
||||
|
||||
// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录
|
||||
func AllWorkspaceNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT ws->>'name' AS name
|
||||
FROM agents, jsonb_array_elements(workspaces) AS ws
|
||||
WHERE COALESCE(ws->>'name', '') <> ''
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsHumanUser 判断某个三维地址 name 位是否为人类用户
|
||||
func IsHumanUser(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
173
gateway/internal/sse/manager.go
Normal file
173
gateway/internal/sse/manager.go
Normal file
@ -0,0 +1,173 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Client 是一个 SSE 连接客户端
|
||||
type Client struct {
|
||||
ID string
|
||||
AgentName string // 非空 = Agent 侧连接
|
||||
UserName string // 非空 = 已登录人类用户的前端连接
|
||||
Res http.ResponseWriter
|
||||
Flusher http.Flusher
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Manager 管理所有 SSE 客户端连接
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*Client
|
||||
}
|
||||
|
||||
var Default = &Manager{
|
||||
clients: make(map[string]*Client),
|
||||
}
|
||||
|
||||
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
|
||||
func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client {
|
||||
flusher, ok := res.(http.Flusher)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := uuid.New().String()[:8]
|
||||
client := &Client{
|
||||
ID: id,
|
||||
AgentName: agentName,
|
||||
UserName: userName,
|
||||
Res: res,
|
||||
Flusher: flusher,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.Header().Set("Content-Type", "text/event-stream")
|
||||
res.Header().Set("Cache-Control", "no-cache")
|
||||
res.Header().Set("Connection", "keep-alive")
|
||||
res.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
m.mu.Lock()
|
||||
m.clients[id] = client
|
||||
m.mu.Unlock()
|
||||
|
||||
// 发送连接确认
|
||||
client.Send("connected", map[string]string{"id": id})
|
||||
|
||||
// 启动心跳
|
||||
go m.heartbeat(client)
|
||||
|
||||
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName)
|
||||
return client
|
||||
}
|
||||
|
||||
// RemoveClient 移除一个客户端
|
||||
func (m *Manager) RemoveClient(id string) {
|
||||
m.mu.Lock()
|
||||
if c, ok := m.clients[id]; ok {
|
||||
close(c.done)
|
||||
delete(m.clients, id)
|
||||
fmt.Printf("[SSE] Client disconnected: %s\n", id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendToAgent 向指定 Agent 名的所有客户端推送事件
|
||||
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
|
||||
if agentName == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == agentName {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 向指定人类用户的所有前端连接推送事件
|
||||
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
|
||||
if userName == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.UserName == userName {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
|
||||
// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent)
|
||||
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == name || c.UserName == name {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 向所有客户端广播事件
|
||||
func (m *Manager) Broadcast(eventType string, data interface{}) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCount 返回当前连接数
|
||||
func (m *Manager) ClientCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.clients)
|
||||
}
|
||||
|
||||
// Send 向单个客户端发送事件
|
||||
func (c *Client) Send(eventType string, data interface{}) {
|
||||
defer func() { recover() }() // 防止向已关闭的连接写入 panic
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
c.Flusher.Flush()
|
||||
}
|
||||
|
||||
// heartbeat 定期发送心跳保活
|
||||
func (m *Manager) heartbeat(client *Client) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// SSE 注释行作为心跳
|
||||
defer func() { recover() }()
|
||||
fmt.Fprintf(client.Res, ": heartbeat\n\n")
|
||||
client.Flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
29
gateway/internal/static/static.go
Normal file
29
gateway/internal/static/static.go
Normal file
@ -0,0 +1,29 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
indexHTML []byte
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func GetIndex() []byte {
|
||||
once.Do(func() {
|
||||
data, _ := fs.ReadFile(staticFS, "static/index.html")
|
||||
indexHTML = data
|
||||
})
|
||||
return indexHTML
|
||||
}
|
||||
|
||||
func Handler() http.Handler {
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
806
plugins/opencode-mail-bridge/index.js
Normal file
806
plugins/opencode-mail-bridge/index.js
Normal file
@ -0,0 +1,806 @@
|
||||
import { z } from "zod";
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname, basename } from "node:path";
|
||||
|
||||
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
|
||||
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
|
||||
// 收到邮件后自动开会话处理时使用的模型
|
||||
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || "llmsproxy";
|
||||
const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || "AUTO";
|
||||
|
||||
// ─── 凭证 ───
|
||||
//
|
||||
// 优先用 Agent 密钥(Authorization: Bearer)。密钥来源按优先级:
|
||||
// 1. AGENTMAIL_AGENT_KEY 环境变量(systemd 部署走这条)
|
||||
// 2. ~/.agentmail/agent.key(首次安装时本地生成并落盘)
|
||||
// 没有密钥时退回旧的 name/secret 方式,保证老配置不被这次改动打断。
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
|
||||
const KEY_FILE = join(CONFIG_DIR, "agent.key");
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
|
||||
const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || "";
|
||||
|
||||
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
|
||||
function readLocalKey() {
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
|
||||
return typeof raw?.key_token === "string" && raw.key_token ? raw.key_token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 首次安装时本地生成密钥并落盘(0600)。 */
|
||||
function generateLocalKey() {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
console.error(`[mail-bridge] 已在 ${KEY_FILE} 生成本地密钥。`);
|
||||
console.error(`[mail-bridge] 该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||
console.error(`[mail-bridge] ${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 把 gateway 地址与密钥记到 config.json,便于换机时人工核对。 */
|
||||
function saveConfig(extra) {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
let cur = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try { cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { /* 损坏就重写 */ }
|
||||
}
|
||||
writeFileSync(
|
||||
CONFIG_FILE,
|
||||
JSON.stringify({ ...cur, gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, ...extra }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[mail-bridge] 写 config.json 失败:", e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 当前生效的密钥:环境变量 > 本地文件 > 无(退回 name/secret)
|
||||
let AGENT_KEY = process.env.AGENTMAIL_AGENT_KEY || readLocalKey() || "";
|
||||
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
function authHeaders() {
|
||||
if (AGENT_KEY) {
|
||||
return { Authorization: `Bearer ${AGENT_KEY}`, "X-Agent-Name": AGENT_NAME };
|
||||
}
|
||||
return { "X-Agent-Name": AGENT_NAME, "X-Agent-Secret": AGENT_SECRET };
|
||||
}
|
||||
|
||||
// ─── HTTP ───
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `POST ${path} failed: ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 把 opencode 侧的会话标题/slug 回写到 AgentMail。
|
||||
// opencode 会在首轮对话后由模型生成会话标题,并配一个短 slug(如 jolly-cactus)——
|
||||
// 不在 AgentMail 侧另造一套命名,平台那边叫什么,这边的 session_alias 就叫什么。
|
||||
async function syncSessionNaming(mailSessionID, { alias, title }) {
|
||||
if (!mailSessionID) return null;
|
||||
if (!alias && !title) return null;
|
||||
try {
|
||||
const res = await apiPost(`/sessions/${mailSessionID}/sync`, {
|
||||
alias: alias || "",
|
||||
title: title || "",
|
||||
});
|
||||
return res;
|
||||
} catch (e) {
|
||||
// 同步是后台润色,失败不该影响邮件主流程,但必须留痕
|
||||
console.error("[mail-bridge] 会话命名同步失败:", e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tools(直接用 zod 定义,不依赖 @opencode-ai/plugin) ───
|
||||
|
||||
const sendMailTool = {
|
||||
description: "发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建会话,.具体别名 必须是已存在的会话(否则无法送达)。回复来信请传 reply_to。",
|
||||
args: {
|
||||
to: z.string().describe("收件人:name / name@path(默认会话)/ name@path.new(新建)/ name@path.别名(已有会话)"),
|
||||
subject: z.string().describe("邮件主题"),
|
||||
body: z.string().describe("邮件正文(Markdown)"),
|
||||
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
||||
reply_to: z.string().optional().describe("回复某封邮件时传其 mail_id,回信会落回同一会话"),
|
||||
session_alias: z.string().optional().describe("仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈。别名全局唯一,不可含 . / @ 空白,不可为 new"),
|
||||
attachment_ids: z.array(z.string()).optional().describe("附件 ID 列表,先用 upload_attachment 上传取得"),
|
||||
propose_alias: z.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"建议把当前会话改名成这个别名(例如摸清问题后从 witty-planet 改成 fix-login-leak)。" +
|
||||
"这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new"
|
||||
),
|
||||
propose_reason: z.string().optional().describe("改名理由,一句话,展示给用户看"),
|
||||
},
|
||||
async execute(args) {
|
||||
// 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
|
||||
// 选注释而不是自造标记:react-markdown 不解析 raw HTML,
|
||||
// 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。
|
||||
let body = args.body;
|
||||
if (args.propose_alias) {
|
||||
const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符
|
||||
const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : "";
|
||||
body += `\n\n<!-- agentmail:rename-session alias="${esc(args.propose_alias)}"${reason} -->`;
|
||||
}
|
||||
|
||||
const result = await apiPost("/mail/send", {
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
body,
|
||||
cc: args.cc || "",
|
||||
reply_to: args.reply_to || "",
|
||||
session_alias: args.session_alias || "",
|
||||
attachment_ids: args.attachment_ids || [],
|
||||
});
|
||||
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
|
||||
? "配额即将用尽,请尽快向人类发送最终总结。"
|
||||
: "")
|
||||
: "";
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过
|
||||
const proposed = result.rename_proposed
|
||||
? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。`
|
||||
: "";
|
||||
return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${quota}${proposed}`;
|
||||
},
|
||||
};
|
||||
|
||||
const forwardMailTool = {
|
||||
description:
|
||||
"转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。" +
|
||||
"只能转发自己参与过的邮件。",
|
||||
args: {
|
||||
mail_id: z.string().describe("要转发的邮件 ID(从 read_inbox 获得)"),
|
||||
to: z.string().describe("新收件人的三维地址"),
|
||||
comment: z.string().optional().describe("转发说明,置于引用原文之前"),
|
||||
cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"),
|
||||
subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"),
|
||||
session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"),
|
||||
},
|
||||
async execute(args) {
|
||||
const result = await apiPost(`/mail/${args.mail_id}/forward`, {
|
||||
to: args.to,
|
||||
comment: args.comment || "",
|
||||
cc: args.cc || "",
|
||||
subject: args.subject || "",
|
||||
session_alias: args.session_alias || "",
|
||||
});
|
||||
return `已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`;
|
||||
},
|
||||
};
|
||||
|
||||
const readInboxTool = {
|
||||
description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。",
|
||||
args: {
|
||||
filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"),
|
||||
limit: z.number().optional().describe("返回数量,默认 5"),
|
||||
},
|
||||
async execute(args) {
|
||||
const filter = args.filter || "unread";
|
||||
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 lines = [
|
||||
`[${m.status}] ${m.from_name}: ${m.subject}`,
|
||||
`邮件 ID: ${m.mail_id}`,
|
||||
`会话: #${m.session_alias || "未命名"}`,
|
||||
];
|
||||
// 必须把 attachment_id 一起给出:不然模型知道「有附件」却无从下载
|
||||
if (m.attachments?.length) {
|
||||
lines.push(
|
||||
"附件: " +
|
||||
m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join("、")
|
||||
);
|
||||
lines.push("下载附件请用 download_attachment 工具。");
|
||||
}
|
||||
lines.push(`内容: ${(m.body_preview || m.body || "").substring(0, 200)}`);
|
||||
return lines.join("\n");
|
||||
}).join("\n\n");
|
||||
},
|
||||
};
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
function formatSize(n) {
|
||||
if (typeof n !== "number") return "?";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const uploadAttachmentTool = {
|
||||
description:
|
||||
"上传本地文件作为邮件附件,返回 attachment_id。" +
|
||||
"拿到 id 后在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。" +
|
||||
"未随邮件发出的附件 24 小时后自动清理。",
|
||||
args: {
|
||||
path: z.string().describe("要上传的本地文件绝对路径"),
|
||||
filename: z.string().optional().describe("自定义展示文件名,默认取路径的最后一段"),
|
||||
},
|
||||
async execute(args) {
|
||||
const filePath = args.path;
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(filePath);
|
||||
} catch {
|
||||
return `文件不存在或不可读: ${filePath}`;
|
||||
}
|
||||
if (!stat.isFile()) return `不是普通文件: ${filePath}`;
|
||||
|
||||
const name = args.filename || basename(filePath);
|
||||
// Node 的 Blob 需要完整读入内存。附件上限 25MB,一次性读入可接受;
|
||||
// 若将来放宽上限,这里要换成流式 multipart。
|
||||
const buf = readFileSync(filePath);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([buf]), name);
|
||||
|
||||
const res = await fetch(`${GATEWAY_URL}/api/v1/attachments`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(), // 不设 Content-Type,交给 FormData 自己带 boundary
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `上传失败: ${res.status}`);
|
||||
|
||||
const a = data.attachment;
|
||||
return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` +
|
||||
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadAttachmentTool = {
|
||||
description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。",
|
||||
args: {
|
||||
attachment_id: z.string().describe("附件 ID"),
|
||||
save_to: z.string().describe("保存到的本地绝对路径"),
|
||||
},
|
||||
async execute(args) {
|
||||
const res = await fetch(`${GATEWAY_URL}/api/v1/attachments/${args.attachment_id}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `下载失败: ${res.status}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
mkdirSync(dirname(args.save_to), { recursive: true });
|
||||
writeFileSync(args.save_to, buf);
|
||||
return `已保存到 ${args.save_to}(${formatSize(buf.length)})`;
|
||||
},
|
||||
};
|
||||
|
||||
// 平台原生权限询问 → 邮件。
|
||||
//
|
||||
// **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 /
|
||||
// permission.updated 事件),模型该做的是正常调工具,由 harness 决定要不要问人。
|
||||
// 让模型主动调一个 request_permission 工具是把 harness 的职责推给模型 ——
|
||||
// 它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。
|
||||
//
|
||||
// relay_key 用 opencode 的 permission.id 做幂等键:permission.updated 会重复触发,
|
||||
// 插件重连也会重放,没有它同一次询问会生成好几封邮件。
|
||||
async function relayPermission({ question, options, context, relayKey }) {
|
||||
return apiPost("/permission/request", {
|
||||
question,
|
||||
options: options && options.length ? options : ["同意", "拒绝"],
|
||||
context: context || "",
|
||||
relay_key: relayKey || "",
|
||||
});
|
||||
}
|
||||
|
||||
const connectToServerTool = {
|
||||
description:
|
||||
"连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
|
||||
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
|
||||
args: {
|
||||
gateway_url: z.string().optional().describe("Gateway 地址,如 https://mail.example.com;省略则用当前配置"),
|
||||
key_token: z.string().optional().describe("管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)"),
|
||||
},
|
||||
async execute(args) {
|
||||
if (args.key_token) {
|
||||
AGENT_KEY = args.key_token.trim();
|
||||
// 管理员给的密钥落盘,重启后仍然可用
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: AGENT_KEY, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
} else if (!AGENT_KEY) {
|
||||
AGENT_KEY = generateLocalKey();
|
||||
}
|
||||
|
||||
const url = (args.gateway_url || GATEWAY_URL).replace(/\/+$/, "");
|
||||
const res = await fetch(`${url}/api/v1/agent/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${AGENT_KEY}` },
|
||||
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: "opencode" }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
// 密钥没登记是最常见的失败,直接把要登记的值给出来,省一轮来回
|
||||
return [
|
||||
`连接失败(HTTP ${res.status}):${data.error || "未知错误"}`,
|
||||
``,
|
||||
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
|
||||
AGENT_KEY,
|
||||
``,
|
||||
`密钥文件:${KEY_FILE}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
saveConfig({ gateway_url: url, registered_at: new Date().toISOString() });
|
||||
return `已连接 ${url},注册为 ${data.agent_name || AGENT_NAME}。`;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
let sseAbort = null;
|
||||
|
||||
function startSSE(onEvent) {
|
||||
if (sseAbort) sseAbort.abort();
|
||||
sseAbort = new AbortController();
|
||||
|
||||
const reconnect = () => {
|
||||
if (sseAbort?.signal.aborted) return;
|
||||
|
||||
fetch(`${GATEWAY_URL}/api/v1/events/stream`, {
|
||||
headers: authHeaders(),
|
||||
signal: sseAbort.signal,
|
||||
}).then((res) => {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) { setTimeout(reconnect, 3000); return; }
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
let evt = "", data = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) evt = line.slice(7).trim();
|
||||
else if (line.startsWith("data: ")) data = line.slice(6);
|
||||
else if (line === "" && evt) {
|
||||
try { onEvent(evt, JSON.parse(data)); } catch {}
|
||||
evt = ""; data = "";
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
read();
|
||||
}).catch(() => setTimeout(reconnect, 5000));
|
||||
};
|
||||
|
||||
reconnect();
|
||||
}
|
||||
|
||||
// ─── Plugin ───
|
||||
|
||||
// AgentMail 会话 ↔ opencode 会话的绑定。
|
||||
// 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定,
|
||||
// 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。
|
||||
const sessionMap = new Map(); // agentmail session_id -> opencode session id
|
||||
const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名)
|
||||
const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
|
||||
// 权限询问的双向定位。
|
||||
//
|
||||
// opencode 的 permission.id 与 AgentMail 的 mail_id 是两个 id 空间:
|
||||
// 转出去时要记住 permission 属于哪个会话(回信地址从那里来),
|
||||
// 人类决策回来时要用 permission.id 去回复 opencode。
|
||||
// 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。
|
||||
const pendingPermissions = new Map(); // permission.id -> { sessionID, callID }
|
||||
|
||||
// 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。
|
||||
// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。
|
||||
const relayedSummaries = new Map(); // opencode session id -> assistant message id
|
||||
|
||||
// 收到邮件后建立的会话,才需要在 idle 时把总结转回去。
|
||||
// 用户在 TUI 里自己开的会话不该被搬进邮件系统。
|
||||
const mailDrivenSessions = new Set(); // opencode session id
|
||||
|
||||
async function resolveSessionForMail(client, directory, data, kind) {
|
||||
const mailSessionID = data.session_id;
|
||||
const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
|
||||
if (bound) return { sessionID: bound, reused: true };
|
||||
|
||||
// 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
|
||||
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。
|
||||
const created = await client.session.create({
|
||||
query: directory ? { directory } : undefined,
|
||||
});
|
||||
const session = created?.data ?? created;
|
||||
const sessionID = session?.id;
|
||||
if (!sessionID) throw new Error("session.create 未返回 id");
|
||||
|
||||
if (mailSessionID) {
|
||||
sessionMap.set(mailSessionID, sessionID);
|
||||
reverseMap.set(sessionID, mailSessionID);
|
||||
mailDrivenSessions.add(sessionID);
|
||||
|
||||
// slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写;
|
||||
// 标题要等模型生成,走 session.updated 事件。
|
||||
if (session.slug) {
|
||||
syncSessionNaming(mailSessionID, { alias: session.slug }).then((res) => {
|
||||
if (res?.alias) console.error(`[mail-bridge] 别名同步 ${sessionID} -> ${res.alias}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
return { sessionID, reused: false };
|
||||
}
|
||||
|
||||
// 把本轮的最终总结转成邮件发回给对方。
|
||||
//
|
||||
// **不消耗配额**(基本原则:配额约束的是模型的自主发信,不是 harness 的转发):
|
||||
// 模型已经把话说完了,插件只是把它搬到邮件里。对搬运收费会导致配额用尽时
|
||||
// Agent 连交代都做不了 —— 而那正是最需要它说话的时刻。
|
||||
//
|
||||
// 触发点是 session.idle:opencode 在一轮跑完(不再有工具调用与生成)时发这个事件,
|
||||
// 此刻的最后一条 assistant 消息就是本轮结论。
|
||||
// 不用 message.updated:那会在流式生成过程中反复触发,转出去的是半截话。
|
||||
async function relaySummary(client, directory, sessionID) {
|
||||
const mailSessionID = reverseMap.get(sessionID);
|
||||
if (!mailSessionID) return null; // 不是邮件驱动的会话,不碰
|
||||
if (!mailDrivenSessions.has(sessionID)) return null;
|
||||
|
||||
// 取最后一条 assistant 文本消息
|
||||
const listed = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: directory ? { directory } : undefined,
|
||||
});
|
||||
const msgs = listed?.data ?? listed ?? [];
|
||||
let last = null;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i];
|
||||
if (m?.info?.role !== "assistant") continue;
|
||||
// 未完成的消息(还在生成/被中断)不转:转出去是半截话
|
||||
if (!m.info.time?.completed) continue;
|
||||
const text = (m.parts || [])
|
||||
.filter(p => p.type === "text" && !p.synthetic && !p.ignored && p.text)
|
||||
.map(p => p.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) { last = { id: m.info.id, text }; }
|
||||
break;
|
||||
}
|
||||
if (!last) return null;
|
||||
|
||||
// 本地去重(服务端另有 relay_key 幂等兜底,这里只是少打一次网关)
|
||||
if (relayedSummaries.get(sessionID) === last.id) return null;
|
||||
|
||||
// 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。
|
||||
const ctx = mailContexts.get(mailSessionID);
|
||||
if (!ctx?.replyTo) return null;
|
||||
|
||||
const res = await apiPost("/mail/send", {
|
||||
to: ctx.replyTo,
|
||||
subject: ctx.subject ? `Re: ${stripRe(ctx.subject)}` : "本轮工作总结",
|
||||
body: last.text,
|
||||
reply_to: ctx.mailID || "",
|
||||
// relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次
|
||||
relay: "summary",
|
||||
relay_key: last.id,
|
||||
});
|
||||
relayedSummaries.set(sessionID, last.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
|
||||
function stripRe(subject) {
|
||||
return String(subject).replace(/^(\s*Re:\s*)+/i, "");
|
||||
}
|
||||
|
||||
// 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。
|
||||
// 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。
|
||||
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
|
||||
// relaySummary 需要 client/directory,而 event 钩子拿不到它们
|
||||
// (只在插件初始化时给一次)。插件启动时把它们闭包进来。
|
||||
let relaySummaryRef = async () => null;
|
||||
|
||||
// 人类决策回来 → 回复 opencode 的原生权限询问。
|
||||
//
|
||||
// 决策语义映射回 opencode 的三态:
|
||||
// 同意 → once (仅这一次)
|
||||
// 一直同意 → always (后续同类不再问)
|
||||
// 拒绝 → reject
|
||||
//
|
||||
// relay_key(= opencode 的 permission.id)由服务端随决策事件回传,
|
||||
// 所以插件重启丢了 pendingPermissions 也能续上 —— 这个映射不能只存在内存里。
|
||||
async function replyPermission(client, directory, data) {
|
||||
const permID = data.relay_key || "";
|
||||
if (!permID) {
|
||||
// 没有上游 id 说明这条权限请求不是插件转发的(例如模型直接调过老的
|
||||
// request_permission,或历史数据)。此时没有可回复的 opencode permission,
|
||||
// 只能把结论作为一段话送进会话。
|
||||
return deliverMail(client, directory, data, "permission");
|
||||
}
|
||||
|
||||
const pending = pendingPermissions.get(permID);
|
||||
const sessionID = pending?.sessionID || sessionMap.get(data.session_id || "");
|
||||
if (!sessionID) {
|
||||
console.error(`[mail-bridge] 权限 ${permID} 找不到对应会话,跳过`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const decision = String(data.decision || "");
|
||||
const response =
|
||||
decision === "一直同意" || decision === "always" ? "always" :
|
||||
decision === "拒绝" || decision === "reject" ? "reject" : "once";
|
||||
|
||||
await client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionID, permissionID: permID },
|
||||
query: directory ? { directory } : undefined,
|
||||
body: { response },
|
||||
});
|
||||
pendingPermissions.delete(permID);
|
||||
console.error(`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`);
|
||||
return { sessionID };
|
||||
}
|
||||
|
||||
// 把一封来信投递给对应的 opencode 会话(已绑定则续谈,未绑定则新开)。
|
||||
async function deliverMail(client, directory, data, kind) {
|
||||
const { sessionID, reused } = await resolveSessionForMail(client, directory, data, kind);
|
||||
|
||||
// 记住这轮该回给谁:idle 时 relaySummary 靠它决定收件人与 reply_to。
|
||||
// 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。
|
||||
if (kind === "mail" && data.session_id) {
|
||||
mailContexts.set(data.session_id, {
|
||||
replyTo: data.from_name || "",
|
||||
subject: data.subject || "",
|
||||
mailID: data.mail_id || "",
|
||||
});
|
||||
}
|
||||
|
||||
const text = kind === "permission"
|
||||
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。`
|
||||
: [
|
||||
reused ? `本会话收到一封新邮件(AgentMail 续谈)。` : `你收到一封新邮件(AgentMail)。`,
|
||||
``,
|
||||
`发件人:${data.from_name || "unknown"}`,
|
||||
`主题:${data.subject || "(无主题)"}`,
|
||||
`邮件 ID:${data.mail_id || "unknown"}`,
|
||||
`身份:你是 ${AGENT_NAME}`,
|
||||
``,
|
||||
`请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`,
|
||||
``,
|
||||
`**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`,
|
||||
`插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || "发件人"}(不消耗你的发信配额)。`,
|
||||
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
|
||||
].join("\n");
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
query: directory ? { directory } : undefined,
|
||||
body: {
|
||||
model: { providerID: REPLY_PROVIDER, modelID: REPLY_MODEL },
|
||||
parts: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
|
||||
return { sessionID, reused };
|
||||
}
|
||||
|
||||
export default async function mailBridge(input) {
|
||||
const { client, directory } = input;
|
||||
|
||||
// 注册 Agent。无密钥也无 secret 时先本地生成一把密钥,
|
||||
// 等管理员在后台登记后即可接入(无需重装插件)。
|
||||
if (!AGENT_KEY && !AGENT_SECRET) {
|
||||
AGENT_KEY = generateLocalKey();
|
||||
}
|
||||
try {
|
||||
await apiPost("/agent/register", {
|
||||
name: AGENT_NAME,
|
||||
secret: AGENT_KEY ? "" : AGENT_SECRET,
|
||||
workspaces: [],
|
||||
platform: "opencode",
|
||||
});
|
||||
saveConfig({ registered_at: new Date().toISOString() });
|
||||
console.error(
|
||||
`[mail-bridge] 已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}` +
|
||||
`(${AGENT_KEY ? "密钥认证" : "name/secret 认证"})。`
|
||||
);
|
||||
} catch (e) {
|
||||
// 密钥未登记时这里会报「密钥无效」——必须说清楚该做什么,
|
||||
// 否则用户只看到一句 401 不知道要拿密钥去后台登记。
|
||||
console.error("[mail-bridge] 注册失败:", e?.message);
|
||||
if (AGENT_KEY) {
|
||||
console.error(`[mail-bridge] 若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥(见 ${KEY_FILE})。`);
|
||||
}
|
||||
}
|
||||
|
||||
// event 钩子里拿不到 client/directory(它们只在插件初始化时给),
|
||||
// 所以用一个闭包把 relaySummary 需要的两个参数固定下来。
|
||||
relaySummaryRef = (sid) => relaySummary(client, directory, sid);
|
||||
|
||||
// 心跳。响应带回配额,配额将要用尽时留一条日志。
|
||||
// 注意插件代劳的转发(权限询问、最终总结)不占配额,
|
||||
// 所以这条警告只关系到模型主动调 send_mail 的次数。
|
||||
let lastQuotaWarn = -1;
|
||||
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(() => {});
|
||||
}, 30000);
|
||||
|
||||
startSSE((type, data) => {
|
||||
// 人类决策了一条权限请求 → 回复 opencode 的原生 permission,让它自己恢复执行。
|
||||
// 这条路径不走 deliverMail:opencode 的权限机制会在收到回复后继续原来的工具调用,
|
||||
// 再往会话里塞一段「你的请求已批准」的文字只会干扰它。
|
||||
if (type === "permission_decision") {
|
||||
replyPermission(client, directory, data).catch((e) => {
|
||||
console.error("[mail-bridge] 权限决策回传失败:", e?.message || e);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== "new_mail") return;
|
||||
deliverMail(client, directory, data, "mail")
|
||||
.then(({ sessionID, reused }) => {
|
||||
console.error(`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
// 失败必须可见,否则邮件会静默丢失
|
||||
console.error(`[mail-bridge] ${type} 处理失败:`, e?.message || e);
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(heartbeat);
|
||||
if (sseAbort) sseAbort.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
// 平台原生的权限询问 → 转成邮件问人。
|
||||
//
|
||||
// 这是 harness 的职责,不该让模型自己调一个 request_permission 工具:
|
||||
// 模型可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。
|
||||
//
|
||||
// 钩子里只**记下**待决策项并转出邮件,status 保持 "ask" —— 不在这里阻塞等人回复:
|
||||
// permission.ask 是同步钩子,卡在这里会把整个 opencode 请求挂住。
|
||||
// 人类决策通过 SSE 回来后,再用 SDK 回复这条 permission。
|
||||
async "permission.ask"(input, output) {
|
||||
if (!mailDrivenSessions.has(input.sessionID)) return; // 非邮件驱动的会话不接管
|
||||
const mailSessionID = reverseMap.get(input.sessionID);
|
||||
if (!mailSessionID) return;
|
||||
|
||||
pendingPermissions.set(input.id, {
|
||||
sessionID: input.sessionID,
|
||||
callID: input.callID || "",
|
||||
});
|
||||
|
||||
try {
|
||||
// opencode 的权限语义是三态,映射成人类看得懂的选项:
|
||||
// 「同意」= once(仅这次),「一直同意」= always(后续同类不再问),「拒绝」= reject
|
||||
await relayPermission({
|
||||
question: input.title || `请求执行 ${input.type}`,
|
||||
options: ["同意", "一直同意", "拒绝"],
|
||||
context: [
|
||||
`类型:${input.type}`,
|
||||
input.pattern ? `目标:${Array.isArray(input.pattern) ? input.pattern.join(", ") : input.pattern}` : "",
|
||||
Object.keys(input.metadata || {}).length
|
||||
? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```"
|
||||
: "",
|
||||
].filter(Boolean).join("\n"),
|
||||
relayKey: input.id,
|
||||
});
|
||||
console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`);
|
||||
} catch (e) {
|
||||
// 转不出去就别让 opencode 挂在那儿等:保持 ask 让本地机制接管(TUI 弹窗)
|
||||
console.error("[mail-bridge] 权限询问转发失败:", e?.message || e);
|
||||
pendingPermissions.delete(input.id);
|
||||
return;
|
||||
}
|
||||
output.status = "ask";
|
||||
},
|
||||
|
||||
async event({ event }) {
|
||||
// 1) opencode 生成/更新会话标题时,把标题与 slug 回写成 AgentMail 的会话命名。
|
||||
// 首轮对话结束后 opencode 才由模型定标题,所以只能靠事件而非创建时刻拿到。
|
||||
if (event?.type === "session.updated") {
|
||||
const info = event.properties?.info;
|
||||
if (!info?.id) return;
|
||||
|
||||
const mailSessionID = reverseMap.get(info.id);
|
||||
if (!mailSessionID) return; // 不是邮件驱动的会话,不碰
|
||||
|
||||
// "New session - <时间>" 是 opencode 的占位标题,等模型生成真摘要再回写
|
||||
const title = typeof info.title === "string" ? info.title : "";
|
||||
if (!title || title.startsWith("New session")) return;
|
||||
|
||||
// 同一标题只回写一次,避免 session.updated 高频触发时反复打网关
|
||||
if (syncedTitles.get(info.id) === title) return;
|
||||
syncedTitles.set(info.id, title);
|
||||
|
||||
const res = await syncSessionNaming(mailSessionID, { alias: info.slug || "", title });
|
||||
if (res) {
|
||||
console.error(`[mail-bridge] 会话命名同步 ${info.id} -> alias=${res.alias || "-"} title=${res.title || "-"}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 一轮跑完 → 把最后那段话作为回信转出去(不消耗配额)。
|
||||
// 用 session.idle 而不是 message.updated:后者在流式生成中反复触发,
|
||||
// 转出去的会是半截话。
|
||||
if (event?.type === "session.idle") {
|
||||
const sid = event.properties?.sessionID;
|
||||
if (!sid || !mailDrivenSessions.has(sid)) return;
|
||||
try {
|
||||
const res = await relaySummaryRef(sid);
|
||||
if (res?.mail_id) {
|
||||
console.error(`[mail-bridge] 总结已回信 ${res.mail_id}(不计配额)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[mail-bridge] 总结回信失败:", e?.message || e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 权限被本地机制(TUI)处理掉时,清掉待决策记录,
|
||||
// 免得之后邮件决策回来又去回复一条已经结案的 permission。
|
||||
if (event?.type === "permission.replied") {
|
||||
const pid = event.properties?.permissionID;
|
||||
if (pid) pendingPermissions.delete(pid);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
tool: {
|
||||
send_mail: sendMailTool,
|
||||
read_inbox: readInboxTool,
|
||||
forward_mail: forwardMailTool,
|
||||
upload_attachment: uploadAttachmentTool,
|
||||
download_attachment: downloadAttachmentTool,
|
||||
connect_to_server: connectToServerTool,
|
||||
},
|
||||
};
|
||||
}
|
||||
448
plugins/opencode-mail-bridge/package-lock.json
generated
Normal file
448
plugins/opencode-mail-bridge/package-lock.json
generated
Normal file
@ -0,0 +1,448 @@
|
||||
{
|
||||
"name": "opencode-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencode-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
||||
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.18.25",
|
||||
"resolved": "https://registry.npmmirror.com/@opencode-ai/plugin/-/plugin-1.18.25.tgz",
|
||||
"integrity": "sha512-Kb34zFqYosFNiMd1IuYiZGjX17z+18Srm7tHZMCz+uMVRTYNkEw1FTrfAK2FLbggwYdgzifGwKMNF1slLT8eLw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "1.18.25",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/keymap": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin/node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.1.8.tgz",
|
||||
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.18.25",
|
||||
"resolved": "https://registry.npmmirror.com/@opencode-ai/sdk/-/sdk-1.18.25.tgz",
|
||||
"integrity": "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.83",
|
||||
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.83.tgz",
|
||||
"integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^7.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^2.0.1",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^14.0.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.9.0.tgz",
|
||||
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-7.0.0.tgz",
|
||||
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.8.tgz",
|
||||
"integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.3.0.tgz",
|
||||
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
plugins/opencode-mail-bridge/package.json
Normal file
13
plugins/opencode-mail-bridge/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "opencode-mail-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Opencode plugin: 邮件驱动多智能体协作平台桥接",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.15.0"
|
||||
}
|
||||
}
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgentMail</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4228
web/package-lock.json
generated
Normal file
4228
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
web/package.json
Normal file
30
web/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "agentmail-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node test/markdown-xss.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
165
web/src/App.tsx
Normal file
165
web/src/App.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect } from 'react';
|
||||
import { connectSSE } from './api/sse';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useMailStore } from './stores/mailStore';
|
||||
import { useSessionStore } from './stores/sessionStore';
|
||||
import { useContactStore } from './stores/contactStore';
|
||||
import { useUIStore } from './stores/uiStore';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MailList from './components/MailList';
|
||||
import ContactPanel from './components/ContactPanel';
|
||||
import MailView from './components/MailView';
|
||||
import ComposePage from './components/ComposePage';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import SetupPage from './components/SetupPage';
|
||||
import AccountPage from './components/AccountPage';
|
||||
import AdminUsersPage from './components/AdminUsersPage';
|
||||
|
||||
export default function App() {
|
||||
const phase = useAuthStore(s => s.phase);
|
||||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const resetUI = useUIStore(s => s.reset);
|
||||
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const dropMailSession = useMailStore(s => s.dropSession);
|
||||
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const dropSessionIfCurrent = useSessionStore(s => s.dropSessionIfCurrent);
|
||||
const refreshRenameProposal = useSessionStore(s => s.refreshRenameProposal);
|
||||
const refreshBudget = useSessionStore(s => s.refreshBudget);
|
||||
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const removeSessionLocally = useContactStore(s => s.removeSessionLocally);
|
||||
|
||||
// 启动时检测初始化状态 / 登录态
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
}, []);
|
||||
|
||||
// 登出后清理客户端状态,避免脏数据残留
|
||||
useEffect(() => {
|
||||
if (phase === 'anonymous') {
|
||||
resetUI();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// 登录态就绪后拉取数据 + SSE
|
||||
useEffect(() => {
|
||||
if (phase !== 'authenticated') return;
|
||||
|
||||
fetchInbox('all');
|
||||
fetchSent();
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
|
||||
return connectSSE((type, data) => {
|
||||
switch (type) {
|
||||
case 'new_mail':
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
// 新来信可能带改名建议;Agent 发信也会消耗本任务的往返预算
|
||||
refreshRenameProposal();
|
||||
refreshBudget();
|
||||
break;
|
||||
case 'session_update':
|
||||
// 别人(或另一个标签页)改了预算/别名
|
||||
refreshBudget();
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
break;
|
||||
case 'permission_decision':
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
break;
|
||||
case 'session_archived': {
|
||||
const id = typeof data.session_id === 'string' ? data.session_id : '';
|
||||
if (!id) break;
|
||||
removeSessionLocally(id);
|
||||
dropMailSession(id);
|
||||
dropSessionIfCurrent(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [phase]);
|
||||
|
||||
// loading 阶段
|
||||
if (phase === 'checking') {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||||
<p className="text-sm">加载中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (phase === 'anonymous') {
|
||||
return <AnonymousRoute onBootDone={bootstrap} />;
|
||||
}
|
||||
|
||||
// 已登录主界面
|
||||
return (
|
||||
<div className="h-full flex bg-gray-50">
|
||||
<Sidebar />
|
||||
{viewMode === 'contacts' ? (
|
||||
<ContactPanel />
|
||||
) : viewMode === 'inbox' || viewMode === 'sent' ? (
|
||||
<MailList />
|
||||
) : null}
|
||||
{composing ? (
|
||||
<ComposePage />
|
||||
) : viewMode === 'account' ? (
|
||||
<AccountPage />
|
||||
) : viewMode === 'admin' && user?.role === 'admin' ? (
|
||||
<AdminUsersPage />
|
||||
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
|
||||
<MailView />
|
||||
) : (
|
||||
<MailView />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 未登录时:先查 needs_setup,决定展示初始化向导还是登录页 */
|
||||
function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) {
|
||||
// bootstrap 已经在 App 里调过了,此处仅判断路由
|
||||
// 若 bootstrap 已经把 phase 推到 anonymous,needs_setup 需独立查询
|
||||
// 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑
|
||||
return <LoginOrSetup onDone={onBootDone} />;
|
||||
}
|
||||
|
||||
import { setupStatus } from './api/client';
|
||||
function LoginOrSetup({ onDone }: { onDone: () => void }) {
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setupStatus()
|
||||
.then(r => setNeedsSetup(r.needs_setup))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, []);
|
||||
|
||||
if (needsSetup === null) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||||
<p className="text-sm">加载中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <SetupPage onDone={onDone} />;
|
||||
}
|
||||
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
import { useState } from 'react';
|
||||
489
web/src/api/client.ts
Normal file
489
web/src/api/client.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
|
||||
import { API_BASE, authHeaders, withToken } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
|
||||
|
||||
const BASE = API_BASE;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
retryAfter?: number;
|
||||
constructor(status: number, message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
// Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
export async function setupStatus() {
|
||||
return request<{ needs_setup: boolean }>('GET', '/setup/status');
|
||||
}
|
||||
|
||||
export async function setupAdmin(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/setup/admin', payload);
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return request<{ user: User }>('POST', '/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return request<{ status: string }>('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
return request<{ user: User }>('GET', '/auth/me');
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', '/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
export async function adminListUsers() {
|
||||
return request<{ users: User[] }>('GET', '/admin/users');
|
||||
}
|
||||
|
||||
export async function adminCreateUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/admin/users', payload);
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(
|
||||
id: string,
|
||||
payload: {
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}
|
||||
) {
|
||||
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function adminDisableUser(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function adminResetPassword(id: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminListScopes() {
|
||||
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- 密钥 ----------
|
||||
|
||||
export type KeyType = 'permanent' | 'one_time' | 'timed';
|
||||
|
||||
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
|
||||
export interface AgentKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
agent_name: string | null;
|
||||
key_type: KeyType;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
label: string;
|
||||
key_type: KeyType;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateKeyPayload {
|
||||
key_type: KeyType;
|
||||
label?: string;
|
||||
/** 仅 timed 需要 */
|
||||
expires_hours?: number;
|
||||
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
|
||||
agent_name?: string;
|
||||
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
|
||||
key_token?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentKeys(agentName?: string) {
|
||||
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
|
||||
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
|
||||
}
|
||||
|
||||
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
|
||||
}
|
||||
|
||||
export async function adminDeleteAgentKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function adminBindAgentKey(id: string, agentName: string) {
|
||||
return request<{ status: string; agent_name: string }>(
|
||||
'POST',
|
||||
`/admin/agent-keys/${id}/bind`,
|
||||
{ agent_name: agentName }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyKeys() {
|
||||
return request<{ keys: UserKey[] }>('GET', '/me/keys');
|
||||
}
|
||||
|
||||
export async function createMyKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: UserKey }>('POST', '/me/keys', payload);
|
||||
}
|
||||
|
||||
export async function deleteMyKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
|
||||
}
|
||||
|
||||
// ---------- Agents ----------
|
||||
|
||||
export async function listAgents(status?: string) {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
|
||||
}
|
||||
|
||||
// ---------- 自己的邮箱 ----------
|
||||
|
||||
export interface SendMailOpts {
|
||||
cc?: string;
|
||||
reply_to?: string;
|
||||
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
|
||||
session_alias?: string;
|
||||
/** 先用 uploadAttachment 上传取得的 id 列表 */
|
||||
attachment_ids?: string[];
|
||||
/**
|
||||
* 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效;
|
||||
* 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
opts: SendMailOpts = {}
|
||||
) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
budget_max?: number;
|
||||
budget_used?: number;
|
||||
budget_remaining?: number;
|
||||
}>('POST', '/me/mail/send', {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc: opts.cc ?? '',
|
||||
reply_to: opts.reply_to ?? '',
|
||||
session_alias: opts.session_alias ?? '',
|
||||
attachment_ids: opts.attachment_ids ?? [],
|
||||
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
||||
max_rounds: opts.max_rounds ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInbox(status = 'all', limit = 50) {
|
||||
return request<{ mails: Mail[]; total: number }>(
|
||||
'GET',
|
||||
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSent() {
|
||||
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
|
||||
}
|
||||
|
||||
export async function getMail(id: string) {
|
||||
return request<Mail>('GET', `/mail/${id}`);
|
||||
}
|
||||
|
||||
export async function markMailRead(id: string) {
|
||||
return request<{ status: string }>('POST', `/mail/${id}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取线索的一块。
|
||||
*
|
||||
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。
|
||||
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
|
||||
*/
|
||||
export async function getMailThread(
|
||||
id: string,
|
||||
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
|
||||
) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.dir) q.set('dir', opts.dir);
|
||||
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
|
||||
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
|
||||
const qs = q.toString();
|
||||
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
/**
|
||||
* 上传附件,返回 attachment_id。
|
||||
*
|
||||
* 不能走 request():那里固定 Content-Type: application/json,
|
||||
* 而 multipart 必须让浏览器自己带 boundary。
|
||||
*/
|
||||
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
|
||||
if (onProgress) {
|
||||
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/me/attachments`);
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
let payload: { attachment?: Attachment; error?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* 非 JSON 响应按状态码处理 */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
|
||||
resolve({ attachment: payload.attachment });
|
||||
} else {
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/me/attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
|
||||
headers: authHeaders(),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401) onUnauthorized?.();
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ attachment: Attachment }>;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
|
||||
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
|
||||
*/
|
||||
export function attachmentURL(id: string) {
|
||||
return withToken(`${BASE}/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
export function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface ForwardPayload {
|
||||
/** 新收件人的三维地址 */
|
||||
to: string;
|
||||
/** 转发说明,置于引用原文之前 */
|
||||
comment?: string;
|
||||
cc?: string;
|
||||
/** 留空则自动加 Fwd: 前缀 */
|
||||
subject?: string;
|
||||
/** 仅当 to 以 .new 结尾时生效 */
|
||||
session_alias?: string;
|
||||
}
|
||||
|
||||
export async function forwardMail(id: string, payload: ForwardPayload) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
forwarded_from: string;
|
||||
}>('POST', `/me/mail/${id}/forward`, {
|
||||
to: payload.to,
|
||||
comment: payload.comment ?? '',
|
||||
cc: payload.cc ?? '',
|
||||
subject: payload.subject ?? '',
|
||||
session_alias: payload.session_alias ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 配额 ----------
|
||||
|
||||
export interface Quota {
|
||||
agent_name: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限额时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export async function adminListQuotas() {
|
||||
return request<{ quotas: Quota[] }>('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);
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
|
||||
export async function getHumanSessions() {
|
||||
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
|
||||
}
|
||||
|
||||
export async function getSessionDetail(id: string) {
|
||||
return request<SessionDetail>('GET', `/sessions/${id}`);
|
||||
}
|
||||
|
||||
export async function updateSessionAlias(id: string, alias: string) {
|
||||
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。
|
||||
* 已接受(提议就是当前别名)或已驳回的不再返回。
|
||||
*/
|
||||
export async function getRenameProposal(id: string) {
|
||||
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
|
||||
}
|
||||
|
||||
/** 本会话(= 本任务)的往返预算。 */
|
||||
export async function getSessionBudget(id: string) {
|
||||
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改本会话的往返预算。
|
||||
*
|
||||
* max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给
|
||||
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
|
||||
*/
|
||||
export async function updateSessionBudget(
|
||||
id: string,
|
||||
patch: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
|
||||
}
|
||||
|
||||
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
|
||||
export async function dismissRenameProposal(id: string) {
|
||||
return request<{ status: string; dismissed?: string }>(
|
||||
'POST',
|
||||
`/sessions/${id}/rename-proposal/dismiss`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Contacts ----------
|
||||
|
||||
export async function listContacts(archived = false) {
|
||||
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
|
||||
}
|
||||
|
||||
export async function suggestAddress(name?: string, path?: string) {
|
||||
const p = new URLSearchParams();
|
||||
if (name) p.set('name', name);
|
||||
if (path) p.set('path', path);
|
||||
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
|
||||
}
|
||||
|
||||
export async function archiveContact(payload: { address?: string; session_id?: string }) {
|
||||
return request<{ status: string; session_id: string; session_alias: string }>(
|
||||
'POST',
|
||||
'/contacts/archive',
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
export async function decidePermission(mailId: string, decision: string, note?: string) {
|
||||
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
|
||||
mail_id: mailId,
|
||||
decision,
|
||||
note: note ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPendingPermissions() {
|
||||
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
|
||||
}
|
||||
68
web/src/api/config.ts
Normal file
68
web/src/api/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* API 接入配置。
|
||||
*
|
||||
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
|
||||
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1;独立部署的客户端需要指向具体主机
|
||||
* 2. 凭证:浏览器用登录 Cookie;第三方客户端用用户密钥(Authorization: Bearer)
|
||||
*
|
||||
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
|
||||
* 不需要改任何调用点。
|
||||
*/
|
||||
|
||||
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
|
||||
declare global {
|
||||
interface Window {
|
||||
__AGENTMAIL_API_BASE__?: string;
|
||||
__AGENTMAIL_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
|
||||
*
|
||||
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
|
||||
*/
|
||||
function resolveBase(): string {
|
||||
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
|
||||
const build = import.meta.env?.VITE_API_BASE as string | undefined;
|
||||
const base = (runtime || build || '/api/v1').trim();
|
||||
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE = resolveBase();
|
||||
|
||||
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
|
||||
let bearerToken: string | null =
|
||||
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
|
||||
|
||||
/**
|
||||
* 设置用户密钥。第三方客户端在启动时调用一次即可,
|
||||
* 之后所有请求(含 SSE 与附件下载)自动带上。
|
||||
*/
|
||||
export function setToken(token: string | null) {
|
||||
bearerToken = token && token.trim() !== '' ? token.trim() : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
/** 认证请求头。用 Cookie 时返回空对象。 */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
* - <a download> / <img src> 由浏览器直接发起
|
||||
*
|
||||
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=,
|
||||
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
|
||||
*/
|
||||
export function withToken(url: string): string {
|
||||
if (!bearerToken) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
|
||||
}
|
||||
76
web/src/api/sse.ts
Normal file
76
web/src/api/sse.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
|
||||
const EVENTS = [
|
||||
'new_mail',
|
||||
'permission_decision',
|
||||
'session_update',
|
||||
'session_archived',
|
||||
'agent_online'
|
||||
] as const;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let handlers: SSEEventHandler[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoff = 1000;
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
if (!es) open();
|
||||
|
||||
return () => {
|
||||
handlers = handlers.filter(h => h !== onEvent);
|
||||
if (handlers.length === 0) close();
|
||||
};
|
||||
}
|
||||
|
||||
function open() {
|
||||
close(false);
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
backoff = 1000;
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
es.addEventListener(name, (e: MessageEvent) => {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* 忽略非 JSON 负载 */
|
||||
}
|
||||
handlers.forEach(h => h(name, data));
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
open();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
function close(clearHandlers = true) {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
if (clearHandlers) handlers = [];
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
close();
|
||||
}
|
||||
218
web/src/components/AccountPage.tsx
Normal file
218
web/src/components/AccountPage.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { LockIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
|
||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||
export default function AccountPage() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 密钥面板状态
|
||||
const [keys, setKeys] = useState<api.UserKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.listMyKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.createMyKey(payload);
|
||||
// 全文只在创建响应里出现一次,必须当场展示
|
||||
setNewToken(r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.deleteMyKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const mismatch = confirmPw !== '' && newPw !== confirmPw;
|
||||
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
|
||||
|
||||
const changePw = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.changePassword(oldPw, newPw);
|
||||
setMsg('密码已修改,请重新登录');
|
||||
setOldPw('');
|
||||
setNewPw('');
|
||||
setConfirmPw('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-6 py-3 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg px-6 py-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row label="用户名" value={user.username} mono />
|
||||
<Row label="显示名" value={user.display_name} />
|
||||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||||
<Row label="创建时间" value={user.created_at || '-'} />
|
||||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 权限边界 */}
|
||||
{user.role !== 'admin' && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row
|
||||
label="可调用 Agent"
|
||||
value={
|
||||
user.allowed_agents.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_agents.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="可访问目录"
|
||||
value={
|
||||
user.allowed_paths.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_paths.join(', ')
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||||
<LockIcon className="w-3.5 h-3.5" />
|
||||
修改密码
|
||||
</h3>
|
||||
<form onSubmit={changePw} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPw}
|
||||
onChange={e => setOldPw(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy ? '保存中' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
variant="user"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3">
|
||||
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
|
||||
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
web/src/components/AddressInput.tsx
Normal file
176
web/src/components/AddressInput.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
|
||||
/**
|
||||
* 三段式地址输入:name -> @path -> .session
|
||||
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
|
||||
* 值本身始终是完整字符串 name@path.session。
|
||||
*/
|
||||
export default function AddressInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowMultiple = false,
|
||||
autoFocus = false
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
/** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */
|
||||
allowMultiple?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||
const { head, editing } = useMemo(() => {
|
||||
if (!allowMultiple) return { head: '', editing: value };
|
||||
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
|
||||
if (idx < 0) return { head: '', editing: value };
|
||||
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
|
||||
}, [value, allowMultiple]);
|
||||
|
||||
// 把编辑段拆成 name / path / session 三部分
|
||||
const parts = useMemo(() => parseParts(editing), [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const res = parts.hasDot
|
||||
? await api.suggestAddress(parts.name, parts.path)
|
||||
: parts.hasAt
|
||||
? await api.suggestAddress(parts.name)
|
||||
: await api.suggestAddress();
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
const filtered = (res.suggestions || []).filter(s =>
|
||||
s.toLowerCase().includes(frag.toLowerCase())
|
||||
);
|
||||
setKind(res.kind);
|
||||
setItems(filtered);
|
||||
setActive(0);
|
||||
} catch {
|
||||
if (!cancelled) setItems([]);
|
||||
}
|
||||
};
|
||||
const t = setTimeout(run, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
if (kind === 'name') {
|
||||
next = `${choice}@`;
|
||||
} else if (kind === 'path') {
|
||||
next = `${parts.name}@${choice}.`;
|
||||
} else {
|
||||
next = `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i + 1) % items.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i - 1 + items.length) % items.length);
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
apply(items[active]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hint =
|
||||
kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名(new 为新建)';
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative">
|
||||
<input
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
|
||||
{open && items.length > 0 && (
|
||||
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
|
||||
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
||||
{hint}
|
||||
</div>
|
||||
{items.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
|
||||
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
{kind === 'session' && s === 'new' && (
|
||||
<span className="ml-2 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
|
||||
function parseParts(s: string) {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
421
web/src/components/AdminUsersPage.tsx
Normal file
421
web/src/components/AdminUsersPage.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { AdminScopes, User } from '../types';
|
||||
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import QuotaPanel from './QuotaPanel';
|
||||
|
||||
type Tab = 'users' | 'keys' | 'quotas';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [tab, setTab] = useState<Tab>('users');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
// Agent 密钥面板
|
||||
const [keys, setKeys] = useState<api.AgentKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]);
|
||||
setUsers(u.users || []);
|
||||
setScopes(s);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListAgentKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'keys') loadKeys();
|
||||
}, [tab, loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.adminCreateAgentKey(payload);
|
||||
// 登记客户端已有密钥时对方已经持有全文,无需再弹一次
|
||||
setNewToken(payload.key_token ? null : r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminDeleteAgentKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const bindKey = async (id: string, agentName: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminBindAgentKey(id, agentName);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setNotice(msg);
|
||||
setTimeout(() => setNotice(null), 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
用户管理
|
||||
<span className="text-xs text-gray-400">{users.length}</span>
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
|
||||
<KeyIcon className="w-4 h-4" />
|
||||
Agent 密钥
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
|
||||
<BotIcon className="w-4 h-4" />
|
||||
发信配额
|
||||
</TabButton>
|
||||
<div className="flex-1" />
|
||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||
{tab === 'users' && (
|
||||
<button onClick={() => setCreating(v => !v)} className="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
|
||||
{creating ? '收起' : '新建用户'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
|
||||
|
||||
{tab === 'quotas' ? (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<QuotaPanel />
|
||||
</div>
|
||||
) : tab === 'keys' ? (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<KeyPanel
|
||||
variant="agent"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onBind={bindKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
|
||||
{users.map(u => (
|
||||
<UserCard key={u.user_id} user={u} scopes={scopes}
|
||||
expanded={editing === u.user_id}
|
||||
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
|
||||
onSaved={flash} onReload={load} setError={setError} />
|
||||
))}
|
||||
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6">加载中</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
||||
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserCard ── */
|
||||
|
||||
function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void;
|
||||
onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<div className="px-4 py-2.5 flex items-center gap-3">
|
||||
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
|
||||
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
||||
<span className="text-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{user.status === 'active' ? '启用' : '禁用'}
|
||||
</span>
|
||||
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
|
||||
<span className="text-[10px] text-gray-400">受限</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
|
||||
</div>
|
||||
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserEditor ── */
|
||||
|
||||
function UserEditor({ user, scopes, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name);
|
||||
const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user');
|
||||
const [agents, setAgents] = useState<string[]>(user.allowed_agents);
|
||||
const [paths, setPaths] = useState<string[]>(user.allowed_paths);
|
||||
const [pw, setPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]);
|
||||
const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths });
|
||||
onSaved('用户已更新'); await onReload();
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disableUser = async () => {
|
||||
try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const enableUser = async () => {
|
||||
try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (pw.length < 8) return;
|
||||
setBusy(true);
|
||||
try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
|
||||
className="w-full 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">状态</label>
|
||||
{user.status === 'active' ? (
|
||||
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full">禁用</button>
|
||||
) : (
|
||||
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full">启用</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可调用 Agent {agents.length > 0 && <span className="text-gray-400">({agents.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.agents.map(a => (
|
||||
<button key={a} onClick={() => toggleAgent(a)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可访问目录 {paths.length > 0 && <span className="text-gray-400">({paths.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限;按目录前缀匹配</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.paths.map(p => (
|
||||
<button key={p} onClick={() => togglePath(p)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
|
||||
{busy ? '保存中' : '保存更改'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<LockIcon className="w-3 h-3 text-gray-400" />
|
||||
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
||||
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
|
||||
<CheckIcon className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CreateUserForm ── */
|
||||
|
||||
function CreateUserForm({ scopes, onDone, onError }: {
|
||||
scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ok = username.trim().length >= 2 && password.length >= 8 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminCreateUser({
|
||||
username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role,
|
||||
allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths,
|
||||
});
|
||||
setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]);
|
||||
onDone();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Field label="用户名" hint="小写字母数字 . _ -">
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
</Field>
|
||||
<Field label="显示名"><input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Alice"
|
||||
className="w-full 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" /></Field>
|
||||
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full 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" /></Field>
|
||||
<Field label="角色">
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{role !== 'admin' && (
|
||||
<>
|
||||
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
|
||||
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={submit} disabled={!ok} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
|
||||
{busy ? '创建中' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopePick({ label, items, selected, onToggle, color }: {
|
||||
label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green';
|
||||
}) {
|
||||
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
{label} <span className="font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map(i => (
|
||||
<button key={i} onClick={() => onToggle(i)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{i}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
web/src/components/Attachments.tsx
Normal file
168
web/src/components/Attachments.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { Attachment } from '../types';
|
||||
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 已发出邮件的附件清单(只读,点击下载)。 */
|
||||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-gray-100 pt-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-[11px] font-medium text-gray-500">
|
||||
附件 {items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li key={a.attachment_id}>
|
||||
<a
|
||||
href={api.attachmentURL(a.attachment_id)}
|
||||
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
|
||||
download={a.filename}
|
||||
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
{api.formatSize(a.size_bytes)}
|
||||
</span>
|
||||
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写信时的附件选择器。
|
||||
*
|
||||
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
|
||||
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
|
||||
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
|
||||
*/
|
||||
export function AttachmentPicker({
|
||||
items,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
items: PendingAttachment[];
|
||||
onChange: (next: PendingAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setError(null);
|
||||
|
||||
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
|
||||
const added: PendingAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
setUploading({ name: file.name, pct: 0 });
|
||||
try {
|
||||
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
|
||||
added.push({
|
||||
id: r.attachment.attachment_id,
|
||||
filename: r.attachment.filename,
|
||||
size: r.attachment.size_bytes
|
||||
});
|
||||
} catch (err) {
|
||||
setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`);
|
||||
break; // 一个失败就停下,避免连续弹同类错误
|
||||
}
|
||||
}
|
||||
setUploading(null);
|
||||
if (added.length > 0) onChange([...items, ...added]);
|
||||
|
||||
// 清空 input,否则重复选同一个文件不会触发 change
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const remove = async (a: PendingAttachment) => {
|
||||
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
|
||||
try {
|
||||
await api.deleteAttachment(a.id);
|
||||
} catch {
|
||||
/* 删不掉也只是留给 GC,不该阻塞用户移除操作 */
|
||||
}
|
||||
onChange(items.filter(x => x.id !== a.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={pick}
|
||||
disabled={disabled || uploading !== null}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
添加附件
|
||||
</button>
|
||||
|
||||
{uploading && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
{uploading.name} {uploading.pct}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{items.length > 0 && !uploading && (
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{items.length} 个附件 ·{' '}
|
||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600">{error}</div>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||||
<button
|
||||
onClick={() => remove(a)}
|
||||
disabled={disabled}
|
||||
title="移除"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
web/src/components/ComposePage.tsx
Normal file
275
web/src/components/ComposePage.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import * as api from '../api/client';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
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';
|
||||
|
||||
/** 完整的写邮件页面,占据右侧整个区域 */
|
||||
export default function ComposePage() {
|
||||
const prefill = useUIStore(s => s.composePrefill);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
|
||||
const [to, setTo] = useState(prefill?.to ?? '');
|
||||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [sessionAlias, setSessionAlias] = useState('');
|
||||
// 本任务的往返预算。空 = 不限。
|
||||
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
||||
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
const [maxRounds, setMaxRounds] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTo(prefill?.to ?? '');
|
||||
setCc(prefill?.cc ?? '');
|
||||
}, [prefill]);
|
||||
|
||||
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
|
||||
// 命中已有会话或走默认会话时后端会忽略该字段。
|
||||
const isNewSession = /\.new\s*$/.test(to.trim());
|
||||
const aliasError =
|
||||
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
|
||||
? '别名不可含 . 空白 / 或 @'
|
||||
: isNewSession && sessionAlias.trim() === 'new'
|
||||
? '"new" 是寻址保留字'
|
||||
: null;
|
||||
|
||||
const roundsError =
|
||||
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
|
||||
? '预算必须是非负整数(0 = 不限)'
|
||||
: null;
|
||||
|
||||
const canSend =
|
||||
roundsError === null &&
|
||||
to.trim() !== '' &&
|
||||
subject.trim() !== '' &&
|
||||
body.trim() !== '' &&
|
||||
aliasError === null &&
|
||||
!sending;
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.sendMail(to.trim(), subject.trim(), body, {
|
||||
cc: cc.trim(),
|
||||
session_alias: isNewSession ? sessionAlias.trim() : '',
|
||||
attachment_ids: attachments.map(a => a.id),
|
||||
// 只在新建会话时提交:续谈已有会话若也带这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算
|
||||
...(isNewSession && maxRounds.trim() !== ''
|
||||
? { max_rounds: Number(maxRounds.trim()) }
|
||||
: {})
|
||||
});
|
||||
const where = res.session_alias
|
||||
? `会话别名 ${res.session_alias}`
|
||||
: `会话 ${res.session_id.slice(0, 8)}`;
|
||||
setOkMsg(
|
||||
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
|
||||
);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
setTimeout(() => {
|
||||
setOkMsg(null);
|
||||
cancelCompose();
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<ComposeIcon className="w-4 h-4 text-blue-600" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">新建邮件</h2>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setSessionAlias('');
|
||||
setMaxRounds('');
|
||||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||||
setAttachments([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-3 border-b border-gray-200">
|
||||
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
||||
<AddressInput
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus
|
||||
placeholder="deepseekharness@/program.upadtefeature"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isNewSession && (
|
||||
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
|
||||
<input
|
||||
value={sessionAlias}
|
||||
onChange={e => setSessionAlias(e.target.value)}
|
||||
placeholder="refactor-auth"
|
||||
className="w-full 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"
|
||||
/>
|
||||
{aliasError && <span className="text-[10px] text-red-600">{aliasError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isNewSession && (
|
||||
<Field
|
||||
label="往返预算"
|
||||
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={maxRounds}
|
||||
onChange={e => setMaxRounds(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
个来回后 Agent 停止主动发信(自动转发的总结与权限询问不占预算)
|
||||
</span>
|
||||
</div>
|
||||
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="抄送" hint="多个地址用逗号分隔">
|
||||
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
|
||||
</Field>
|
||||
|
||||
<Field label="主题">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="更新特性分支"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 py-3 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
||||
<div className="flex-1" />
|
||||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||||
编辑
|
||||
</Toggle>
|
||||
<Toggle active={preview} onClick={() => setPreview(true)}>
|
||||
预览
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none">
|
||||
{body.trim() ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
||||
className="flex-1 min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-3">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!canSend}
|
||||
className="px-5 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{sending ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
active,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-[11px] px-2 py-0.5 rounded ${
|
||||
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
217
web/src/components/ContactPanel.tsx
Normal file
217
web/src/components/ContactPanel.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 左侧联系人面板:列出所有 name@path.session,支持
|
||||
* - 点击进入该会话
|
||||
* - 写信(预填收件人为该三维地址)
|
||||
* - 归档(Agent 侧会话归档 + 邮箱界面移除)
|
||||
*/
|
||||
export default function ContactPanel() {
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
const archivedContacts = useContactStore(s => s.archivedContacts);
|
||||
const showArchived = useContactStore(s => s.showArchived);
|
||||
const loading = useContactStore(s => s.loading);
|
||||
const error = useContactStore(s => s.error);
|
||||
const pendingArchive = useContactStore(s => s.pendingArchive);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
|
||||
const requestArchive = useContactStore(s => s.requestArchive);
|
||||
const cancelArchive = useContactStore(s => s.cancelArchive);
|
||||
const archive = useContactStore(s => s.archive);
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
selectSession(c.session_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
|
||||
<h2 className="text-sm font-semibold text-gray-800">联系人</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">加载中</p>
|
||||
)}
|
||||
|
||||
{contacts.map(c => (
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
confirming={pendingArchive === c.address}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onRequestArchive={() => requestArchive(c.address)}
|
||||
onCancelArchive={cancelArchive}
|
||||
onConfirmArchive={() => archive(c)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">
|
||||
暂无联系人,发一封邮件即可建立
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showArchived && (
|
||||
<div className="pt-3 mt-2 border-t border-gray-200">
|
||||
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
|
||||
已归档 {archivedContacts.length}
|
||||
</p>
|
||||
{archivedContacts.map(c => (
|
||||
<div
|
||||
key={c.session_id}
|
||||
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
|
||||
>
|
||||
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{c.mail_count} 封 · 已归档
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{archivedContacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">无归档会话</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
active,
|
||||
confirming,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive,
|
||||
onCancelArchive,
|
||||
onConfirmArchive
|
||||
}: {
|
||||
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',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
|
||||
<p className="text-xs text-gray-800">
|
||||
归档 <span className="font-mono">{contact.address}</span>?
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={onConfirmArchive}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
确认归档
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelArchive}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="w-full text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">
|
||||
{contact.agent_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||
{contact.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{contact.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{contact.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{contact.mail_count} 封 · {time}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="flex gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onRequestArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
web/src/components/KeyPanel.tsx
Normal file
311
web/src/components/KeyPanel.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
|
||||
|
||||
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
|
||||
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
|
||||
permanent: '长期',
|
||||
one_time: '一次性',
|
||||
timed: '限时'
|
||||
};
|
||||
|
||||
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
|
||||
permanent: '永不过期,可重复使用',
|
||||
one_time: '首次使用后立即失效',
|
||||
timed: '指定小时数后过期'
|
||||
};
|
||||
|
||||
/** 一条密钥在列表里的状态:过期/已用完/可用 */
|
||||
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
|
||||
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
|
||||
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
|
||||
return { text: '已过期', cls: 'text-red-500' };
|
||||
return { text: '可用', cls: 'text-green-600' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 新签发密钥的一次性展示条。
|
||||
*
|
||||
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
|
||||
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
|
||||
*/
|
||||
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 无剪贴板权限时用户可手动选中 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-amber-900">
|
||||
密钥已创建。全文仅显示这一次,关闭后无法再次查看。
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
|
||||
{token}
|
||||
</code>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<button onClick={onDismiss} className="shrink-0 text-xs text-amber-800 hover:underline">
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateFormProps {
|
||||
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
|
||||
variant: 'agent' | 'user';
|
||||
busy: boolean;
|
||||
onSubmit: (payload: api.CreateKeyPayload) => void;
|
||||
}
|
||||
|
||||
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
|
||||
const [label, setLabel] = useState('');
|
||||
const [hours, setHours] = useState(24);
|
||||
const [agentName, setAgentName] = useState('');
|
||||
const [keyToken, setKeyToken] = useState('');
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
新建密钥
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
|
||||
if (keyType === 'timed') payload.expires_hours = hours;
|
||||
if (variant === 'agent') {
|
||||
if (agentName.trim()) payload.agent_name = agentName.trim();
|
||||
if (keyToken.trim()) payload.key_token = keyToken.trim();
|
||||
}
|
||||
onSubmit(payload);
|
||||
setOpen(false);
|
||||
setLabel('');
|
||||
setAgentName('');
|
||||
setKeyToken('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setKeyType(t)}
|
||||
className={`text-left px-2.5 py-2 rounded border text-xs ${
|
||||
keyType === t
|
||||
? 'border-blue-400 bg-white ring-2 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="备注(如 我的笔记本 / CI 机器)"
|
||||
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
{keyType === 'timed' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">小时后过期</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variant === 'agent' && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={agentName}
|
||||
onChange={e => setAgentName(e.target.value)}
|
||||
placeholder="绑定到 Agent(留空 = 首次注册时自动落定)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<input
|
||||
value={keyToken}
|
||||
onChange={e => setKeyToken(e.target.value)}
|
||||
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
|
||||
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
|
||||
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
|
||||
*/
|
||||
export default function KeyPanel({
|
||||
variant,
|
||||
keys,
|
||||
loading,
|
||||
error,
|
||||
newToken,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onBind,
|
||||
onDismissToken
|
||||
}: {
|
||||
variant: 'agent' | 'user';
|
||||
keys: (api.AgentKey | api.UserKey)[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
newToken: string | null;
|
||||
onCreate: (payload: api.CreateKeyPayload) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onBind?: (id: string, agentName: string) => void;
|
||||
onDismissToken: () => void;
|
||||
}) {
|
||||
const [bindingID, setBindingID] = useState<string | null>(null);
|
||||
const [bindName, setBindName] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
|
||||
</h3>
|
||||
<div className="flex-1" />
|
||||
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{variant === 'agent'
|
||||
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
|
||||
: '第三方客户端用该密钥访问自己的邮箱(Authorization: Bearer)。它不能用于注册 Agent。'}
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无密钥</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{keys.map(k => {
|
||||
const st = keyState(k);
|
||||
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
|
||||
return (
|
||||
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
|
||||
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
|
||||
{k.token_hint}
|
||||
</code>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-900 truncate">
|
||||
{k.label || <span className="text-gray-400">(无备注)</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">
|
||||
{KEY_TYPE_LABEL[k.key_type]}
|
||||
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
|
||||
{agentKey &&
|
||||
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
|
||||
|
||||
{agentKey && onBind && bindingID === k.key_id ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
value={bindName}
|
||||
onChange={e => setBindName(e.target.value)}
|
||||
placeholder="Agent 名"
|
||||
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (bindName.trim()) onBind(k.key_id, bindName.trim());
|
||||
setBindingID(null);
|
||||
setBindName('');
|
||||
}}
|
||||
className="text-[11px] text-blue-600 hover:underline"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBindingID(null)}
|
||||
className="text-[11px] text-gray-500 hover:underline"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
agentKey &&
|
||||
onBind && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setBindingID(k.key_id);
|
||||
setBindName(agentKey.agent_name ?? '');
|
||||
}}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
|
||||
>
|
||||
绑定
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(k.key_id)}
|
||||
title="吊销"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
web/src/components/LoginPage.tsx
Normal file
108
web/src/components/LoginPage.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuthStore(s => s.login);
|
||||
const error = useAuthStore(s => s.error);
|
||||
const retryAfter = useAuthStore(s => s.retryAfter);
|
||||
const submitting = useAuthStore(s => s.submitting);
|
||||
const clearError = useAuthStore(s => s.clearError);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const userRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 被限速时倒计时
|
||||
useEffect(() => {
|
||||
if (!retryAfter) return;
|
||||
setCountdown(retryAfter);
|
||||
const t = setInterval(() => {
|
||||
setCountdown(c => {
|
||||
if (c <= 1) {
|
||||
clearInterval(t);
|
||||
clearError();
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [retryAfter]);
|
||||
|
||||
const locked = countdown > 0;
|
||||
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
const ok = await login(username, password);
|
||||
if (!ok) setPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[380px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
|
||||
<p className="mt-1 text-xs text-gray-500">邮件驱动的多智能体协作平台</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">用户名</label>
|
||||
<input
|
||||
ref={userRef}
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value);
|
||||
if (error) clearError();
|
||||
}}
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value);
|
||||
if (error) clearError();
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
{locked && `(${countdown} 秒后可重试)`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||||
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
web/src/components/MailList.tsx
Normal file
137
web/src/components/MailList.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { ShieldIcon, PaperclipIcon } from './icons';
|
||||
|
||||
export default function MailList() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const sent = useMailStore(s => s.sent);
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const selectMail = useMailStore(s => s.selectMail);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'sent') fetchSent();
|
||||
else if (viewMode === 'inbox') fetchInbox('all');
|
||||
}, [viewMode]);
|
||||
|
||||
const isSent = viewMode === 'sent';
|
||||
const list = isSent ? sent : inbox;
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{list.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMail?.mail_id === m.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(m)}
|
||||
/>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">暂无邮件</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailItem({
|
||||
mail,
|
||||
active,
|
||||
showTo,
|
||||
onClick
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
showTo: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const isUnread = mail.status === 'unread';
|
||||
const ccCount = mail.cc_list?.length ?? 0;
|
||||
const attachCount = mail.attachments?.length ?? 0;
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const peer = showTo
|
||||
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
|
||||
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 font-mono ${
|
||||
isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
{isPermission && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{mail.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
|
||||
)}
|
||||
{ccCount > 0 && (
|
||||
<span className="text-[10px] text-gray-400">抄送 {ccCount}</span>
|
||||
)}
|
||||
{attachCount > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{attachCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
578
web/src/components/MailView.tsx
Normal file
578
web/src/components/MailView.tsx
Normal file
@ -0,0 +1,578 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import ThreadView from './ThreadView';
|
||||
|
||||
export default function MailView() {
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const markRead = useMailStore(s => s.markRead);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
|
||||
// 转发面板作用于哪封邮件;null = 未打开
|
||||
const [forwarding, setForwarding] = useState<Mail | null>(null);
|
||||
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
|
||||
const [threadOf, setThreadOf] = useState<string | null>(null);
|
||||
|
||||
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
|
||||
const currentMailID = currentMail?.mail_id;
|
||||
useEffect(() => {
|
||||
setThreadOf(null);
|
||||
}, [currentMailID]);
|
||||
|
||||
if (currentSession && currentSessionMails.length > 0) {
|
||||
const last = currentSessionMails[currentSessionMails.length - 1];
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 font-mono">
|
||||
{currentSession.session_alias
|
||||
? `.${currentSession.session_alias}`
|
||||
: '(未命名会话)'}
|
||||
</span>
|
||||
<StatusBadge status={currentSession.status} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{currentSessionMails.length} 封
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<BudgetEditor />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
|
||||
</div>
|
||||
<RenameProposalBar />
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
||||
{currentSessionMails.map(m => (
|
||||
<ThreadCard key={m.mail_id} mail={m} />
|
||||
))}
|
||||
</div>
|
||||
<ReplyBar replyTo={last} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentMail) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<div className="text-center">
|
||||
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<p className="text-sm mt-3">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (threadOf) {
|
||||
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<Header
|
||||
mail={currentMail}
|
||||
onRead={() => markRead(currentMail.mail_id)}
|
||||
onForward={() => setForwarding(currentMail)}
|
||||
onThread={() => setThreadOf(currentMail.mail_id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={currentMail.attachments ?? []} />
|
||||
{currentMail.mail_type === 'permission_request' && (
|
||||
<PermissionPanel mail={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本任务的往返预算编辑器(会话头部)。
|
||||
*
|
||||
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
|
||||
* 而不是「这件事值得多少个来回」。
|
||||
*/
|
||||
function BudgetEditor() {
|
||||
const budget = useSessionStore(s => s.budget);
|
||||
const setBudget = useSessionStore(s => s.setBudget);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!budget) return null;
|
||||
|
||||
const exhausted = !budget.unlimited && budget.remaining === 0;
|
||||
|
||||
const open = () => {
|
||||
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
|
||||
setBusy(true);
|
||||
await setBudget(patch);
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={open}
|
||||
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
|
||||
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
|
||||
exhausted
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
<GaugeIcon className="w-3 h-3" />
|
||||
{budget.unlimited
|
||||
? '预算不限'
|
||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-gray-500">往返预算</span>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
autoFocus
|
||||
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
invalid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
disabled={busy || invalid}
|
||||
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
|
||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || budget.used_rounds === 0}
|
||||
onClick={() => commit({ reset: true })}
|
||||
title="已用次数归零,上限不变"
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-[11px] text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 提议改会话别名的提示条。
|
||||
*
|
||||
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口(name@path.别名)。
|
||||
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
function RenameProposalBar() {
|
||||
const proposal = useSessionStore(s => s.renameProposal);
|
||||
const current = useSessionStore(s => s.currentSession);
|
||||
const accept = useSessionStore(s => s.acceptRename);
|
||||
const dismiss = useSessionStore(s => s.dismissRename);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!proposal) return null;
|
||||
|
||||
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
|
||||
|
||||
return (
|
||||
<div className="px-6 py-2.5 bg-blue-50 border-b border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-900">
|
||||
Agent 建议把会话别名从 <span className="font-mono">{from}</span> 改为{' '}
|
||||
<span className="font-mono font-semibold">.{proposal.alias}</span>
|
||||
</p>
|
||||
{proposal.reason && (
|
||||
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-blue-500 mt-0.5">
|
||||
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
|
||||
接受后此别名不再被 Agent 平台的自动命名覆盖
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
await accept();
|
||||
setBusy(false);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded bg-blue-500 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{busy ? '改名中' : '接受'}
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
|
||||
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
|
||||
*/
|
||||
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||
const [to, setTo] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
|
||||
const submit = async () => {
|
||||
if (!to.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.forwardMail(mail.mail_id, { to: to.trim(), comment: comment.trim() });
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white px-6 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-[11px] font-medium text-gray-600">
|
||||
转发「{mail.subject}」
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">原文将以引用块附在下方</span>
|
||||
</div>
|
||||
|
||||
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人:pi@root.new" />
|
||||
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="转发说明(可选,置于引用原文之前)"
|
||||
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !to.trim()}
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '转发中' : '转发'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
mail,
|
||||
onRead,
|
||||
onForward,
|
||||
onThread
|
||||
}: {
|
||||
mail: Mail;
|
||||
onRead: () => void;
|
||||
onForward: () => void;
|
||||
onThread: () => void;
|
||||
}) {
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
|
||||
mail.session_alias ? '.' + mail.session_alias : ''
|
||||
}`;
|
||||
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h2 className="text-sm font-semibold text-gray-900">{mail.subject}</h2>
|
||||
{mail.status === 'unread' && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
|
||||
未读
|
||||
</span>
|
||||
)}
|
||||
{mail.mail_type === 'permission_request' && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
|
||||
<ShieldIcon className="w-3 h-3" />
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{mail.status === 'unread' && (
|
||||
<button onClick={onRead} className="text-xs text-blue-500 hover:underline">
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onThread}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
title="沿回复与转发关系展开整条线索"
|
||||
>
|
||||
<TreeIcon className="w-3.5 h-3.5" />
|
||||
对话树
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ForwardIcon className="w-3.5 h-3.5" />
|
||||
转发
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl className="text-xs text-gray-500 space-y-0.5">
|
||||
<Row label="发件">{from}</Row>
|
||||
<Row label="收件">{to}</Row>
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<Row label="抄送">{mail.cc_list.map(a => a.raw).join('、')}</Row>
|
||||
)}
|
||||
<Row label="时间">{time}</Row>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
||||
<dd className="font-mono text-gray-600 break-all">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ mail }: { mail: Mail }) {
|
||||
const isHuman = mail.from_name === 'human';
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
isPermission
|
||||
? 'border-orange-200 bg-orange-50'
|
||||
: isHuman
|
||||
? 'border-blue-200 bg-blue-50/60'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||
{isHuman ? (
|
||||
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
|
||||
) : (
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-800 font-mono">
|
||||
{isHuman ? 'human' : mail.from_name}
|
||||
</span>
|
||||
{isPermission && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<span className="text-[10px] text-gray-400">抄送 {mail.cc_list.length}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{time}</span>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={mail.attachments ?? []} />
|
||||
{isPermission && <PermissionPanel mail={mail} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [decided, setDecided] = useState(mail.permission_result || '');
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
|
||||
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||
|
||||
const decide = async (choice: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.decidePermission(mail.mail_id, choice, note || undefined);
|
||||
setDecided(choice);
|
||||
await fetchInbox('all');
|
||||
if (mail.session_id) selectSession(mail.session_id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (decided) {
|
||||
return (
|
||||
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
|
||||
已处理:<strong className="text-gray-800">{decided}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => decide(opt)}
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
{isApprove(opt) ? (
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="备注(可选)"
|
||||
className="mt-2 w-full text-xs 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyBar({ replyTo }: { replyTo?: Mail }) {
|
||||
const [body, setBody] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
|
||||
if (!replyTo) return null;
|
||||
|
||||
// 回给对端:若这封是我(human)发的,则回给收件人,否则回给发件人
|
||||
const peerName = replyTo.from_name === 'human' ? replyTo.to_name : replyTo.from_name;
|
||||
const peerPath = replyTo.from_name === 'human' ? replyTo.to_workspace : replyTo.from_workspace;
|
||||
const target = `${peerName}@${peerPath || ''}${
|
||||
replyTo.session_alias ? '.' + replyTo.session_alias : ''
|
||||
}`;
|
||||
|
||||
const send = async () => {
|
||||
if (!body.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
||||
reply_to: replyTo.mail_id,
|
||||
attachment_ids: attachments.map(a => a.id)
|
||||
});
|
||||
setBody('');
|
||||
setAttachments([]);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
if (replyTo.session_id) selectSession(replyTo.session_id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white px-6 py-3">
|
||||
<p className="text-[10px] text-gray-400 mb-1 font-mono">回复 {target}</p>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="回复内容(Markdown)"
|
||||
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setBody('')}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !body.trim()}
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
|
||||
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
|
||||
};
|
||||
const b = map[status] || map.active;
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
|
||||
}
|
||||
130
web/src/components/QuotaPanel.tsx
Normal file
130
web/src/components/QuotaPanel.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 发信配额面板(管理员)。
|
||||
*
|
||||
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
|
||||
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
|
||||
*/
|
||||
export default function QuotaPanel() {
|
||||
const [quotas, setQuotas] = useState<api.Quota[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListQuotas();
|
||||
setQuotas(r.quotas);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminSetQuota(name, payload);
|
||||
await load();
|
||||
setDrafts(d => {
|
||||
const next = { ...d };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 发信配额</h3>
|
||||
<span className="text-xs text-gray-400">{quotas.length}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
限制 Agent 主动发信的次数(不限制收信)。上限填 0 表示不限。
|
||||
剩余次数会随心跳与发信响应回传给 Agent,好让它在额度用尽前主动发最终总结。
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
|
||||
{quotas.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{quotas.map(q => {
|
||||
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
|
||||
const dirty = draft !== String(q.max_rounds);
|
||||
const exhausted = !q.unlimited && q.remaining === 0;
|
||||
return (
|
||||
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||
{q.agent_name}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{q.unlimited ? (
|
||||
<span className="text-xs text-gray-500">不限额(已用 {q.used_rounds})</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
|
||||
style={{
|
||||
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
|
||||
>
|
||||
{q.used_rounds}/{q.max_rounds}
|
||||
{exhausted && ' · 已用尽'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft}
|
||||
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
|
||||
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
|
||||
disabled={!dirty || busy === q.agent_name}
|
||||
title="保存上限"
|
||||
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => apply(q.agent_name, { reset: true })}
|
||||
disabled={busy === q.agent_name || q.used_rounds === 0}
|
||||
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
web/src/components/SetupPage.tsx
Normal file
134
web/src/components/SetupPage.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
|
||||
export default function SetupPage({ onDone }: { onDone: () => void }) {
|
||||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const mismatch = confirm !== '' && password !== confirm;
|
||||
const ok =
|
||||
username.trim().length >= 2 &&
|
||||
password.length >= 8 &&
|
||||
!mismatch &&
|
||||
!busy;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setupAdmin({
|
||||
username: username.trim().toLowerCase(),
|
||||
password,
|
||||
display_name: displayName.trim()
|
||||
});
|
||||
// 初始化后直接登录(后端已经种了 cookie),拉取用户态
|
||||
await bootstrap();
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[420px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">初始化系统</h1>
|
||||
<p className="mt-1 text-xs text-gray-500 text-center">
|
||||
这是系统首次启动。创建一个管理员账号以开始使用 AgentMail。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
管理员用户名 <span className="text-red-400">(即三维地址的 name 位)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-400">
|
||||
小写字母数字 . _ -,2-64 位;后续可以 `admin@.new` 形式作为收件人
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="系统管理员"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
密码 <span className="text-red-400">(至少 8 位)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次输入的密码不一致</p>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ok}
|
||||
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||||
{busy ? '初始化中' : '创建管理员并进入'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
web/src/components/Sidebar.tsx
Normal file
109
web/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
LogoutIcon
|
||||
} from './icons';
|
||||
|
||||
const navItems: {
|
||||
short: string;
|
||||
title: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
|
||||
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
|
||||
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const unread = inbox.filter(m => m.status === 'unread').length;
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
|
||||
{navItems
|
||||
.filter(n => !n.adminOnly || isAdmin)
|
||||
.map(({ short, title, mode, Icon }) => {
|
||||
const active = viewMode === mode && !composing;
|
||||
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
title={title}
|
||||
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[9px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[9px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
|
||||
>
|
||||
<LogoutIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
web/src/components/ThreadView.tsx
Normal file
291
web/src/components/ThreadView.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 对话树视图(按方向分块加载)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
*
|
||||
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
|
||||
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
*
|
||||
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
|
||||
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
|
||||
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
|
||||
*/
|
||||
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
|
||||
const [nodes, setNodes] = useState<ThreadNode[]>([]);
|
||||
const [hidden, setHidden] = useState(0);
|
||||
const [moreUp, setMoreUp] = useState(false);
|
||||
const [moreDown, setMoreDown] = useState(false);
|
||||
const [nextUp, setNextUp] = useState(0);
|
||||
const [nextDown, setNextDown] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinel = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
|
||||
// 读 state 会双双看到 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
const added = incoming.filter(n => !seen.has(n.mail_id));
|
||||
// 按相对深度排;同深度保持服务端给的时间序
|
||||
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { dir: 'around', limit: 40 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
|
||||
setHidden(p.hidden);
|
||||
setMoreUp(p.has_more_up);
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextUp(p.next_up);
|
||||
setNextDown(p.next_down);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (dir: 'up' | 'down') => {
|
||||
if (busy.current) return;
|
||||
if (dir === 'up' && !moreUp) return;
|
||||
if (dir === 'down' && !moreDown) return;
|
||||
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
|
||||
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
|
||||
const el = scrollRef.current;
|
||||
const beforeHeight = el?.scrollHeight ?? 0;
|
||||
const beforeTop = el?.scrollTop ?? 0;
|
||||
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, {
|
||||
dir,
|
||||
offset: dir === 'up' ? nextUp : nextDown,
|
||||
limit: 40
|
||||
});
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
if (dir === 'up') {
|
||||
setMoreUp(p.has_more_up);
|
||||
setNextUp(p.next_up);
|
||||
} else {
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextDown(p.next_down);
|
||||
}
|
||||
if (dir === 'up' && el) {
|
||||
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
},
|
||||
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
|
||||
);
|
||||
|
||||
// 两端各一个哨兵,进入视口就加载对应方向。
|
||||
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
if (e.target === topSentinel.current) loadMore('up');
|
||||
if (e.target === bottomSentinel.current) loadMore('down');
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (topSentinel.current) obs.observe(topSentinel.current);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
|
||||
// 否则祖先未加载时首屏内容会整体缩进一大截。
|
||||
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{(moreUp || moreDown) && ',滑动加载更多'}
|
||||
{hidden > 0 && `,${hidden} 封无权查看`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{initial && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600">{err}</p>}
|
||||
|
||||
{!initial && (
|
||||
<>
|
||||
<div ref={topSentinel} className="h-px" />
|
||||
{moreUp && (
|
||||
<button
|
||||
onClick={() => loadMore('up')}
|
||||
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||||
>
|
||||
加载更早的往来
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{moreDown && (
|
||||
<button
|
||||
onClick={() => loadMore('down')}
|
||||
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||||
>
|
||||
加载后续分支
|
||||
</button>
|
||||
)}
|
||||
<div ref={bottomSentinel} className="h-px" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
node,
|
||||
baseDepth,
|
||||
anchorID
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
baseDepth: number;
|
||||
anchorID: string;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
|
||||
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => openMailByID(node.mail_id)}
|
||||
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
|
||||
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{node.from_workspace ? (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{node.from_name}
|
||||
{node.from_workspace && `@${node.from_workspace}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">→</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
<div className="flex-1" />
|
||||
{node.parent_hidden && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
|
||||
title="上一封不在你的可见范围内"
|
||||
>
|
||||
上游不可见
|
||||
</span>
|
||||
)}
|
||||
{isPermission && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
{node.attachment_count > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{node.attachment_count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||||
{node.body_preview && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||||
)}
|
||||
{node.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
web/src/components/icons.tsx
Normal file
289
web/src/components/icons.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
// 纯 SVG 图标,全站不使用 emoji
|
||||
type P = { className?: string };
|
||||
|
||||
const D = 'w-5 h-5';
|
||||
|
||||
function Svg({ className = D, children }: P & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SentIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposeIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="10" x="3" y="11" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<path d="M8 16h.01" />
|
||||
<path d="M16 16h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
|
||||
<path d="M10 12h4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon({ className = D }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LockIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailboxIcon({ className = 'w-8 h-8' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z" />
|
||||
<path d="M6 8h4" />
|
||||
<path d="M12 19V5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpinnerIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3 21 2" />
|
||||
<path d="m17 6 3 3" />
|
||||
<path d="m14 9 3 3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="12" height="12" x="9" y="9" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m15 17 5-5-5-5" />
|
||||
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperclipIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M13.2 2.8a5 5 0 0 1 7 7l-8.5 8.5a3.2 3.2 0 0 1-4.5-4.5l8-8a1.4 1.4 0 0 1 2 2l-7.5 7.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 11 5 5 5-5" />
|
||||
<path d="M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
<rect x="3" y="17" width="6" height="4" rx="1" />
|
||||
<rect x="15" y="17" width="6" height="4" rx="1" />
|
||||
<path d="M12 7v4M6 17v-3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0l-7.2-7.2A2 2 0 0 1 3 12V4a1 1 0 0 1 1-1h8a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z" />
|
||||
<circle cx="7.5" cy="7.5" r="1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GaugeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 14 15.5 9" />
|
||||
<path d="M3.5 17a9 9 0 1 1 17 0" />
|
||||
<circle cx="12" cy="14" r="1.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
13
web/src/index.css
Normal file
13
web/src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
70
web/src/stores/authStore.ts
Normal file
70
web/src/stores/authStore.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
type Phase = 'checking' | 'anonymous' | 'authenticated';
|
||||
|
||||
interface AuthState {
|
||||
phase: Phase;
|
||||
user: User | null;
|
||||
error: string | null;
|
||||
retryAfter: number | null;
|
||||
submitting: boolean;
|
||||
|
||||
bootstrap: () => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
/** 401 时由 api client 回调 */
|
||||
markAnonymous: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>(set => ({
|
||||
phase: 'checking',
|
||||
user: null,
|
||||
error: null,
|
||||
retryAfter: null,
|
||||
submitting: false,
|
||||
|
||||
bootstrap: async () => {
|
||||
try {
|
||||
const { user } = await api.me();
|
||||
set({ phase: 'authenticated', user, error: null });
|
||||
} catch {
|
||||
set({ phase: 'anonymous', user: null });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ submitting: true, error: null, retryAfter: null });
|
||||
try {
|
||||
const { user } = await api.login(username.trim(), password);
|
||||
set({ phase: 'authenticated', user, submitting: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const retry = err instanceof ApiError ? err.retryAfter ?? null : null;
|
||||
set({ error: msg, retryAfter: retry, submitting: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* 即使请求失败也在前端登出 */
|
||||
}
|
||||
set({ phase: 'anonymous', user: null, error: null });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null, retryAfter: null }),
|
||||
|
||||
markAnonymous: () => set({ phase: 'anonymous', user: null })
|
||||
}));
|
||||
|
||||
// 注册全局 401 处理:任何接口返回 401 即回到登录页
|
||||
api.setUnauthorizedHandler(() => {
|
||||
useAuthStore.getState().markAnonymous();
|
||||
});
|
||||
81
web/src/stores/contactStore.ts
Normal file
81
web/src/stores/contactStore.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Contact } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface ContactState {
|
||||
contacts: Contact[];
|
||||
archivedContacts: Contact[];
|
||||
showArchived: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** 正在等待归档确认的地址 */
|
||||
pendingArchive: string | null;
|
||||
|
||||
fetchContacts: () => Promise<void>;
|
||||
fetchArchived: () => Promise<void>;
|
||||
toggleArchivedView: () => void;
|
||||
requestArchive: (address: string) => void;
|
||||
cancelArchive: () => void;
|
||||
archive: (contact: Contact) => Promise<void>;
|
||||
/** SSE session_archived 到达时本地即时移除 */
|
||||
removeSessionLocally: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactState>((set, get) => ({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
showArchived: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
pendingArchive: null,
|
||||
|
||||
fetchContacts: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { contacts } = await api.listContacts(false);
|
||||
set({ contacts: contacts || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchived: async () => {
|
||||
try {
|
||||
const { contacts } = await api.listContacts(true);
|
||||
set({ archivedContacts: contacts || [] });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
toggleArchivedView: () => {
|
||||
const next = !get().showArchived;
|
||||
set({ showArchived: next });
|
||||
if (next) get().fetchArchived();
|
||||
},
|
||||
|
||||
requestArchive: address => set({ pendingArchive: address }),
|
||||
cancelArchive: () => set({ pendingArchive: null }),
|
||||
|
||||
archive: async contact => {
|
||||
try {
|
||||
await api.archiveContact({ session_id: contact.session_id });
|
||||
// 本地即时移除,不等 SSE
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== contact.session_id),
|
||||
pendingArchive: null
|
||||
}));
|
||||
if (get().showArchived) get().fetchArchived();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
pendingArchive: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeSessionLocally: sessionId =>
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== sessionId)
|
||||
}))
|
||||
}));
|
||||
91
web/src/stores/mailStore.ts
Normal file
91
web/src/stores/mailStore.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Mail } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface MailState {
|
||||
inbox: Mail[];
|
||||
sent: Mail[];
|
||||
currentMail: Mail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchInbox: (status?: string) => Promise<void>;
|
||||
fetchSent: () => Promise<void>;
|
||||
selectMail: (mail: Mail) => void;
|
||||
/**
|
||||
* 按 id 打开邮件。
|
||||
*
|
||||
* 列表与对话树里的邮件只带正文预览(整棵树带全文可能几百 KB),
|
||||
* 所以点开时得单取一次拿全文与附件清单。
|
||||
*/
|
||||
openMailByID: (id: string) => Promise<void>;
|
||||
clearCurrentMail: () => void;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
|
||||
dropSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useMailStore = create<MailState>((set, get) => ({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchInbox: async (status = 'all') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSent: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getSent();
|
||||
set({ sent: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectMail: mail => set({ currentMail: mail }),
|
||||
|
||||
openMailByID: async id => {
|
||||
try {
|
||||
const mail = await api.getMail(id);
|
||||
set({ currentMail: mail });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentMail: () => set({ currentMail: null }),
|
||||
|
||||
markRead: async id => {
|
||||
try {
|
||||
await api.markMailRead(id);
|
||||
set(state => ({
|
||||
inbox: state.inbox.map(m => (m.mail_id === id ? { ...m, status: 'read' as const } : m)),
|
||||
currentMail:
|
||||
state.currentMail?.mail_id === id
|
||||
? { ...state.currentMail, status: 'read' as const }
|
||||
: state.currentMail
|
||||
}));
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dropSession: sessionId => {
|
||||
const { currentMail } = get();
|
||||
set(state => ({
|
||||
inbox: state.inbox.filter(m => m.session_id !== sessionId),
|
||||
sent: state.sent.filter(m => m.session_id !== sessionId),
|
||||
currentMail: currentMail?.session_id === sessionId ? null : currentMail
|
||||
}));
|
||||
}
|
||||
}));
|
||||
150
web/src/stores/sessionStore.ts
Normal file
150
web/src/stores/sessionStore.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { create } from 'zustand';
|
||||
import type { HumanSession, Mail, RenameProposal, Session, SessionBudget } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface SessionState {
|
||||
sessions: HumanSession[];
|
||||
currentSession: Session | null;
|
||||
currentSessionMails: Mail[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Agent 在正文里提的改名建议;null = 无待处理建议 */
|
||||
renameProposal: RenameProposal | null;
|
||||
|
||||
fetchSessions: () => Promise<void>;
|
||||
selectSession: (sessionId: string) => Promise<void>;
|
||||
clearSession: () => void;
|
||||
/** 重新拉取当前会话的改名建议(收到新邮件时调) */
|
||||
refreshRenameProposal: () => Promise<void>;
|
||||
/** 接受建议:改名成功后清掉提示条并刷新会话 */
|
||||
acceptRename: () => Promise<void>;
|
||||
/** 驳回建议:服务端记下来,不再反复弹同一个 */
|
||||
dismissRename: () => Promise<void>;
|
||||
|
||||
/** 本任务的往返预算;null = 尚未取到 */
|
||||
budget: SessionBudget | null;
|
||||
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
|
||||
/** 重新拉取预算(Agent 发信后剩余会变) */
|
||||
refreshBudget: () => Promise<void>;
|
||||
/** 归档后若正查看该会话则退出 */
|
||||
dropSessionIfCurrent: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
currentSessionMails: [],
|
||||
renameProposal: null,
|
||||
budget: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSessions: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { sessions } = await api.getHumanSessions();
|
||||
set({ sessions: sessions || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async sessionId => {
|
||||
set({ loading: true, error: null, renameProposal: null, budget: null });
|
||||
try {
|
||||
const detail = await api.getSessionDetail(sessionId);
|
||||
set({
|
||||
currentSession: detail.session,
|
||||
currentSessionMails: detail.mails || [],
|
||||
loading: false
|
||||
});
|
||||
// 改名建议与预算单独取:拿不到不该让整个会话打不开
|
||||
get().refreshRenameProposal();
|
||||
get().refreshBudget();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshRenameProposal: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const { proposal } = await api.getRenameProposal(id);
|
||||
// 期间用户可能已切走,别把上一会话的建议贴到新会话上
|
||||
if (get().currentSession?.session_id === id) {
|
||||
set({ renameProposal: proposal });
|
||||
}
|
||||
} catch {
|
||||
// 建议是锦上添花,失败静默
|
||||
}
|
||||
},
|
||||
|
||||
acceptRename: async () => {
|
||||
const s = get();
|
||||
const id = s.currentSession?.session_id;
|
||||
const alias = s.renameProposal?.alias;
|
||||
if (!id || !alias) return;
|
||||
try {
|
||||
await api.updateSessionAlias(id, alias);
|
||||
set({ renameProposal: null });
|
||||
// 别名变了,会话详情与列表里的地址都要跟着更新
|
||||
await get().selectSession(id);
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
// 别名被别人占用(409)等情况要让用户看到,不能默默失败
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissRename: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
// 先隐藏提示条再发请求:驳回是纯本地意图,等一个往返才消失显得迟钝
|
||||
set({ renameProposal: null });
|
||||
try {
|
||||
await api.dismissRenameProposal(id);
|
||||
} catch {
|
||||
// 记不下来最坏的后果是下次打开又弹一次,不值得打扰用户
|
||||
}
|
||||
},
|
||||
|
||||
refreshBudget: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.getSessionBudget(id);
|
||||
// 期间用户可能已切走,别把上一会话的预算贴到新会话上
|
||||
if (get().currentSession?.session_id === id) set({ budget: b });
|
||||
} catch {
|
||||
// 预算读不到不影响看邮件,静默
|
||||
}
|
||||
},
|
||||
|
||||
setBudget: async patch => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.updateSessionBudget(id, patch);
|
||||
set({ budget: b });
|
||||
// 列表里也显示预算,改完要跟着刷新
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: () =>
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
|
||||
|
||||
dropSessionIfCurrent: sessionId => {
|
||||
if (get().currentSession?.session_id === sessionId) {
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null });
|
||||
}
|
||||
set(state => ({
|
||||
sessions: state.sessions.filter(s => s.session_id !== sessionId)
|
||||
}));
|
||||
}
|
||||
}));
|
||||
29
web/src/stores/uiStore.ts
Normal file
29
web/src/stores/uiStore.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewMode = 'inbox' | 'sent' | 'contacts' | 'admin' | 'account';
|
||||
|
||||
interface UIState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
|
||||
/** 右侧主区域是否处于「新建邮件」整页编写态 */
|
||||
composing: boolean;
|
||||
composePrefill: { to?: string; cc?: string } | null;
|
||||
startCompose: (prefill?: { to?: string; cc?: string }) => void;
|
||||
cancelCompose: () => void;
|
||||
|
||||
/** 登出后重置回默认视图 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>(set => ({
|
||||
viewMode: 'inbox',
|
||||
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
|
||||
|
||||
composing: false,
|
||||
composePrefill: null,
|
||||
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
|
||||
cancelCompose: () => set({ composing: false, composePrefill: null }),
|
||||
|
||||
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
|
||||
}));
|
||||
212
web/src/types/index.ts
Normal file
212
web/src/types/index.ts
Normal file
@ -0,0 +1,212 @@
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'disabled';
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
last_login?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
agent_id?: string;
|
||||
agent_name: string;
|
||||
workspaces: Workspace[];
|
||||
platform: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count?: number;
|
||||
/**
|
||||
* 别名是谁定的:
|
||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
*/
|
||||
alias_source?: 'platform' | 'manual';
|
||||
/** 用户驳回过的改名提议 */
|
||||
rename_dismissed?: string;
|
||||
/**
|
||||
* 本任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
*
|
||||
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
|
||||
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
}
|
||||
|
||||
/** 会话往返预算快照 */
|
||||
export interface SessionBudget {
|
||||
session_id: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
|
||||
export interface Attachment {
|
||||
attachment_id: string;
|
||||
/** 为 null 表示已上传但尚未随邮件发出 */
|
||||
mail_id: string | null;
|
||||
uploader: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
from_name: string;
|
||||
from_workspace: string;
|
||||
to_name: string;
|
||||
to_workspace: string;
|
||||
cc_list: Address[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mail_type: 'normal' | 'permission_request';
|
||||
permission_options: string[] | null;
|
||||
permission_result: string | null;
|
||||
status: 'unread' | 'read' | 'archived';
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话树节点。
|
||||
*
|
||||
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
|
||||
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
|
||||
*
|
||||
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
||||
*/
|
||||
export interface ThreadNode extends Omit<Mail, 'body'> {
|
||||
depth: number;
|
||||
attachment_count: number;
|
||||
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
|
||||
detached?: boolean;
|
||||
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */
|
||||
parent_hidden?: boolean;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ThreadPage {
|
||||
anchor_mail_id: string;
|
||||
dir: 'around' | 'up' | 'down';
|
||||
nodes: ThreadNode[];
|
||||
total: number;
|
||||
/** 因权限被过滤掉的节点数 */
|
||||
hidden: number;
|
||||
has_more_up: boolean;
|
||||
has_more_down: boolean;
|
||||
/** 下一页 offset,原样回传即可 */
|
||||
next_up: number;
|
||||
next_down: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 在邮件正文里提议的新会话别名。
|
||||
*
|
||||
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
|
||||
* (name@path.别名)。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
export interface RenameProposal {
|
||||
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
|
||||
alias: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
path: string;
|
||||
session_alias: string;
|
||||
address: string;
|
||||
status: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
last_activity: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
request_id: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
result: string | null;
|
||||
decided_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
session: Session;
|
||||
mails: Mail[];
|
||||
}
|
||||
|
||||
export interface HumanSession {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
}
|
||||
|
||||
export type SuggestKind = 'name' | 'path' | 'session';
|
||||
|
||||
export interface SuggestResult {
|
||||
kind: SuggestKind;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/** 系统初始化状态 */
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
/** 管理员可授权范围候选 */
|
||||
export interface AdminScopes {
|
||||
agents: string[];
|
||||
paths: string[];
|
||||
}
|
||||
11
web/src/vite-env.d.ts
vendored
Normal file
11
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 构建期可注入的环境变量 */
|
||||
interface ImportMetaEnv {
|
||||
/** API 基地址;不设则用同源 /api/v1 */
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
8
web/tailwind.config.js
Normal file
8
web/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
43
web/test/markdown-xss.test.mjs
Normal file
43
web/test/markdown-xss.test.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
|
||||
// react-markdown 默认不解析 raw HTML(无 rehype-raw),且用 defaultUrlTransform
|
||||
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
|
||||
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
|
||||
//
|
||||
// 运行:node test/markdown-xss.test.mjs
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import React from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// 只有【真实标签】里的危险内容才算漏。
|
||||
// 注意不能直接搜 onerror=:raw HTML 被转义成 <img … onerror=" 后,
|
||||
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
|
||||
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
|
||||
|
||||
const payloads = [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'[click](javascript:alert(1))',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<iframe src="https://evil.com"></iframe>',
|
||||
')',
|
||||
'<div onmouseover="alert(1)">hover</div>',
|
||||
'[ok](https://example.com)',
|
||||
'**bold** `code`',
|
||||
];
|
||||
|
||||
let leaks = 0;
|
||||
for (const p of payloads) {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
|
||||
);
|
||||
const bad = dangerous.test(html);
|
||||
if (bad) leaks++;
|
||||
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
|
||||
}
|
||||
if (leaks > 0) {
|
||||
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n通过:raw HTML 被转义,javascript: URL 被清空');
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Gateway 默认监听 8180(与 config.Load() 的 PORT 默认值一致)
|
||||
'/api': {
|
||||
target: 'http://localhost:8180',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user