## 症状
单封邮件的元信息三行都不对(生产实测那封 12:12:12):
发件 jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
收件 pi@/home/program/agentmail
抄送 pi@/home/program/agentmail.new
人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。
## 三处错
**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。
**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。
**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。
## 修法
`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:
Agent → pi@/home/program/agentmail.日程提醒:… 三段齐全
人 → jianf 裸名字
`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。
## 顺带修掉 `dsh@dsh`
改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。
会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。
## formatAddress 与后端对齐
第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:
jianf@.任务 → name=jianf path="" session=任务 ✓
jianf.任务 → name="jianf.任务" ✗
现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。
## 取舍
列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。
## homeagent 日程工具的两个修复(同批)
**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。
**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。
## 验收
- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
261 lines
9.6 KiB
Go
261 lines
9.6 KiB
Go
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"`
|
||
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回(0 = 不限)。
|
||
// 真正的额度在每条会话上(sessions.max_rounds),这里只是默认值。
|
||
DefaultRounds int `json:"default_rounds"`
|
||
// UsedRounds 是累计发信数,纯统计,不拦请求。
|
||
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
|
||
// 而 Agent 是长期在线的,所以已降级为观测数据。
|
||
UsedRounds int `json:"used_rounds"`
|
||
LastSeen *time.Time `json:"last_seen"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// 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"`
|
||
|
||
// SessionWorkspace 是**这条会话**的工作目录(sessions.workspace)。
|
||
//
|
||
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
|
||
// - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||
// - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名
|
||
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
|
||
//
|
||
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
|
||
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
|
||
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
|
||
SessionWorkspace string `json:"session_workspace,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"`
|
||
}
|