## 配额重构:废除 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 把插件测试也纳入部署前门禁
259 lines
12 KiB
SQL
259 lines
12 KiB
SQL
-- AgentMail Schema — SQLite(默认后端)
|
||
--
|
||
-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言:
|
||
-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成)
|
||
-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time)
|
||
-- 默认值不用 CURRENT_TIMESTAMP:它只有【秒】精度,同一秒内插入的多行
|
||
-- 按 created_at 排序结果不确定,
|
||
-- 「会话里最早/最后那封邮件」都会取错行
|
||
-- (实测同秒插 5 封,排出来的顺序是乱的)。
|
||
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
|
||
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
|
||
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
|
||
-- 直接决定 UI 上「最新进展」显示哪一封。
|
||
-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索)
|
||
-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责)
|
||
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
|
||
--
|
||
-- 本文件只建表建索引,不含数据迁移: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 派生),数据库只存元数据。
|
||
-- 不塞 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 (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;
|