Files
MailUI4Agents/server/internal/models/models.go
JianFeeeee 5b6fef764f feat(appearance): 主题与壁纸搬到服务端(账号级)—— 回答"为什么背景存在本地"
用户质问:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写
localStorage:换设备/换浏览器就没了,而且**多账号共用一份**(键是全局常量
`agentmail.background`)—— 同一台机器换账号背景不跟着走。而 localStorage 的 ~5MB
配额也解释了客户端那套"压到 2.4MB 以内"的限制本来就是为本地存储设计的。

现在:**服务端是权威(账号级),本地只是缓存**(首屏秒开、离线可用)。

## 服务端

- 新表 `user_appearance`(两种方言),用**列**而不是 JSON:blob GC 要一眼看出
  "这张图还有没有人用"。
- `/api/v1/me/appearance`:GET / PUT(主题+背景档)/ POST image(multipart)/
  GET image / DELETE image。鉴权同其余 /me/*(cookie 或 Bearer)。
- 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256;上限 4MB 兜底
  (客户端会先压到 ~2.4MB),只收图片类型(非图片 415 —— 浏览器会把非图片渲染成
  空白,用户只会看到"设置了却没变化"),超限 413 不静默截断。
- ★ **blob GC 的引用源加了这张表**:我在实现前先读了 `SweepUnreferencedBlobs`,
  它只认 attachments / calendar_attachments。漏了这一处,壁纸会在下次 GC 时被当
  孤儿删掉,而库里那行还在 —— 表现为"图 404、设置却显示已设置"。判据同时验了
  壁纸存活**与**孤儿确实被清(否则"还在"可能只是因为 GC 没跑)。

## 客户端

- `lib/appearance.ts`(纯函数:两侧形状换算、data URL→Blob)+ `stores/appearanceSync.ts`
  (pull / push / 去抖订阅 / 账号切换重新拉取)。
- 三条不变量都有判据:拉取以服务端为准;★ **拉取不会再推回去**(否则是自触发回环,
  一次拉取顺带一次 PUT,服务端 updated_at 被无意义刷新);本地改动会推上去。
- 壁纸**只在换图时上传一次**(几 MB 不该每次 PUT 都跟着走)。
- 降级**必须可见**:未登录/不可达 → `local-only`,推失败 → `pending`,背景设置里
  有徽标与说明("已同步 / 待同步 / 仅本机")。静默降级会让人以为已经同步,
  然后在另一台机器上发现没有 —— 正是这次的缺陷。
- 图片用**带认证的 fetch** 取回再转 data URL:`<img src>` 发不出 Bearer,而
  `?token=` 会把密钥写进历史记录与服务端日志(明确不做)。

## 判据

- Go 10 条:往返、★多账号隔离、非法值归一、上传/取回字节一致、非图片 415、
  超限 413、删除、未登录 401(五个端点)、★GC 存活 + 孤儿对照。
- 客户端 10 条:形状换算、image 无图退回 none、越界夹取、拉取生效、
  ★拉取不推送、推送 payload、未登录/500 → local-only、推失败 → pending、
  ★壁纸只上传一次。
- 全量:server 10 包全绿、客户端 249 通过(含打包一致性判据 —— 它先红后绿,
  因为前端改了必须重打安装包,这条护栏是先前特意留下的)。

## 线上验证与交付

- jianf 设置 → 回包 saved=true;**gui-lab 读到自己那份默认值**(隔离生效);
  gui-lab 上传 67B PNG → 取回 sha256 一致、`has_image=true`;DELETE 后 404。
- 网关已重打(WebUI 内嵌)并部署;Electron 安装包已重打(AppImage + deb)。

遗留:鸿蒙端还没有外观功能(数据已在服务端,将来可直接读);本地缓存仍在(离线可用)。
2026-09-14 08:32:22 +08:00

378 lines
15 KiB
Go
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.

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"`
// ModeEnforcement 是该平台插件自报的权限档位强制能力native / advisory
// 随心跳上报(与模型目录同一条通道,见 I-1平台自己说的才算
//
// 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
// homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
// 把这个事实藏起来比做不到本身更危险。
ModeEnforcement string `json:"mode_enforcement"`
}
// 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"`
// PermissionMode 声明本任务允许 Agent 动手到什么程度:
// plan / workspace / full。空值按 DefaultPermissionMode 处理。
PermissionMode string `json:"permission_mode"`
// PermissionEnforcement 记录接收平台是否真正强制了权限档位:
// native / advisory。它描述执行事实不与 PermissionMode 混为一谈。
PermissionEnforcement string `json:"permission_enforcement"`
}
// 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"`
PermissionKind string `json:"permission_kind,omitempty"`
PermissionMulti bool `json:"permission_multi_select,omitempty"`
// PermissionExpiresAt 是这条权限待办的失效时刻(仅仍未决策的 permission_request 有)。
//
// 超过它之后,提出询问的插件很可能已不再阻塞等待(见 models.PermissionWaitWindow
// 刻意只给**时刻**而不给「已失效」布尔值:布尔值是「发出那一刻」的快照,
// 经 SSE 缓存后会永久停在旧值;时刻则任何客户端都能随时比出现在过没过期。
//
// 由读路径按 CreatedAt 推导后填充,**不落库** —— 它是时间的函数,存下来会失真。
PermissionExpiresAt *time.Time `json:"permission_expires_at,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 代替:
// - 人 → Agentto_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"`
// FromHuman 表示发件方是人类用户而不是 Agent。
//
// 插件靠它判「要不要自动转发本轮结论」Agent 之间不自动回,
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
//
// **补拉路径必须有它**SSE 事件里叫 `from_human`,而插件重启后走
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
FromHuman bool `json:"from_human"`
// ToHuman 表示收件方是人类用户而不是 Agent判据与 FromHuman 同源:
// to_name 是否存在于 users 表)。
//
// 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字,
// Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 ——
// 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留),
// 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。
ToHuman bool `json:"to_human"`
// PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。
//
// **补拉路径必须有它们**(与 FromHuman 同一个理由SSE 事件里叫
// `permission_mode` / `permission_enforcement`,而插件重启后走
// `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件
// 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后
// 惄惄变成了 workspace 档。
PermissionMode string `json:"permission_mode"`
PermissionEnforcement string `json:"permission_enforcement"`
}
// 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"`
Kind string `json:"kind"` // permission | question
MultiSelect bool `json:"multi_select"` // 仅 question 使用
Result *string `json:"result"`
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
// ExpiresAt 是等待窗口的截止时刻(见 PermissionWaitWindow
// 客户端用它自行判断「这条待办是否可能已经没人等了」,服务端不替它下结论。
ExpiresAt time.Time `json:"expires_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"`
}
// Appearance 是**账号级**的用户外观(主题 + 壁纸)。
//
// 背景的取值范围刻意收窄kind 三值、dim/blur 有上限),因为服务端要
// 兜住"客户端被改坏"的情况:这些值最终会写进 CSS 变量,越界值会让界面不可读。
type Appearance struct {
Theme string `json:"theme"` // light / dark / system
BgKind string `json:"bg_kind"` // none / preset / image
BgPresetID string `json:"bg_preset_id"`
BgDim int `json:"bg_dim"` // 遮罩强度 %0-90
BgBlur int `json:"bg_blur"` // 照片模糊 px0-40
ImageSHA256 string `json:"-"`
ImageType string `json:"-"`
ImageBytes int64 `json:"image_bytes"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// DefaultAppearance 是"从没设置过"时的外观 —— 与客户端 backgroundStore /
// themeStore 的默认值一致dim 12、blur 4 是 2026-09-13 壁纸修复后的取值)。
func DefaultAppearance() Appearance {
return Appearance{Theme: "system", BgKind: "none", BgPresetID: "aurora", BgDim: 12, BgBlur: 4}
}
// NormalizeAppearance 把客户端传来的值夹进合法范围。
// 认不出的 kind/theme 一律退回默认 —— 静默接受非法值会让界面白屏且无从排查。
func NormalizeAppearance(a Appearance) Appearance {
switch a.Theme {
case "light", "dark", "system":
default:
a.Theme = "system"
}
switch a.BgKind {
case "none", "preset", "image":
default:
a.BgKind = "none"
}
if a.BgPresetID == "" {
a.BgPresetID = "aurora"
}
if len(a.BgPresetID) > 64 {
a.BgPresetID = a.BgPresetID[:64]
}
if a.BgDim < 0 {
a.BgDim = 0
}
if a.BgDim > 90 {
a.BgDim = 90
}
if a.BgBlur < 0 {
a.BgBlur = 0
}
if a.BgBlur > 40 {
a.BgBlur = 40
}
return a
}