Files
MailUI4Agents/gateway/internal/db/migrations/init_sqlite.sql
JianFeeeee 07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00

259 lines
12 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- AgentMail Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- 默认值不用 CURRENT_TIMESTAMP它只有【秒】精度同一秒内插入的多行
-- 按 created_at 排序结果不确定,
-- 「会话里最早/最后那封邮件」都会取错行
-- (实测同秒插 5 封,排出来的顺序是乱的)。
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
-- 直接决定 UI 上「最新进展」显示哪一封。
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
--
-- 本文件只建表建索引不含数据迁移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 (strftime('%Y-%m-%d %H:%M:%f','now')),
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 (strftime('%Y-%m-%d %H:%M:%f','now')),
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',
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上;
-- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。
default_rounds INTEGER NOT NULL DEFAULT 20,
-- max_rounds / used_rounds 是历史遗留的「终身额度」。
-- 终身额度是错的工具:跑满就得管理员手工重置才能再干活,
-- 而 Agent 是长期在线的。现已降级为纯统计used_rounds 只累加、不拦请求),
-- max_rounds 保留列但不再参与判断。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
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 (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
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 (strftime('%Y-%m-%d %H:%M:%f','now')),
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 (strftime('%Y-%m-%d %H:%M:%f','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;
-- ---------- 密钥认证体系 ----------
--
-- 两类密钥,共享一个全局唯一的 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 (strftime('%Y-%m-%d %H:%M:%f','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 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 (strftime('%Y-%m-%d %H:%M:%f','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 派生),数据库只存元数据。
-- 不塞 BLOBSQLite 的 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 (strftime('%Y-%m-%d %H:%M:%f','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 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 (strftime('%Y-%m-%d %H:%M:%f','now'))
);
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;