From 55b3f9bc4ea16c40c56b976448ecb9fbcb84218f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 13:49:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=9C=B0=E5=9D=80=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=8C=89=E3=80=8C=E4=BA=BA=20/=20Agent=E3=80=8D?= =?UTF-8?q?=E5=88=86=E7=BB=B4=E5=BA=A6=20=E2=80=94=E2=80=94=20=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E8=B7=9F=20Agent=20=E8=B5=B0=EF=BC=8C=E4=BA=BA?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=E5=90=8D=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 症状 单封邮件的元信息三行都不对(生产实测那封 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) --- gateway/internal/models/models.go | 61 ++++--- gateway/internal/repo/repo.go | 20 +-- gateway/internal/repo/thread.go | 4 +- plugins/homeagent-mail-bridge/schedule.go | 40 ++++- web/src/components/MailList.tsx | 21 ++- web/src/components/MailView.tsx | 49 ++++-- web/src/components/ThreadView.tsx | 12 +- web/src/lib/replyTarget.ts | 95 ++++++++++- web/src/types/index.ts | 12 ++ web/test/components/replyTarget.test.tsx | 192 +++++++++++++++++++++- web/test/manual/addr-verify.mjs | 98 +++++++++++ 11 files changed, 531 insertions(+), 73 deletions(-) create mode 100644 web/test/manual/addr-verify.mjs diff --git a/gateway/internal/models/models.go b/gateway/internal/models/models.go index e33fc63..a30fdf7 100644 --- a/gateway/internal/models/models.go +++ b/gateway/internal/models/models.go @@ -9,13 +9,13 @@ import ( // Agent 代表一个已注册的 Agent 实例 type Agent struct { - ID uuid.UUID `json:"agent_id"` - Name string `json:"agent_name"` - Secret string `json:"-"` - HostURL string `json:"host_url"` + 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"` + Platform string `json:"platform"` + Status string `json:"status"` // DefaultRounds 是派给该 Agent 的新任务默认多少个来回(0 = 不限)。 // 真正的额度在每条会话上(sessions.max_rounds),这里只是默认值。 DefaultRounds int `json:"default_rounds"` @@ -122,25 +122,38 @@ func (u User) CanUsePath(path string) bool { // 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"` + 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"` + + // 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"` @@ -234,7 +247,7 @@ func TokenHint(token string) string { // Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。 // // MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作 -//(Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。 +// (Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。 type Attachment struct { ID uuid.UUID `json:"attachment_id"` MailID *uuid.UUID `json:"mail_id"` diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index fd69f49..84eee2f 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -341,14 +341,14 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) { `SELECT 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, m.rename_alias, m.rename_reason + m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.mail_id = $1`, id, ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &renameAlias, &renameReason) + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason) if err != nil { return nil, err } @@ -381,7 +381,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode q := `SELECT 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 + m.status, m.created_at, s.session_alias, s.workspace FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) @@ -410,7 +410,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil { return nil, err } if len(ccJSON) > 0 { @@ -451,7 +451,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e `SELECT 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 + m.status, m.created_at, s.session_alias, s.workspace FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.session_id = $1 @@ -469,7 +469,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil { return nil, err } if len(ccJSON) > 0 { @@ -570,13 +570,13 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode `SELECT 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 + m.status, m.created_at, s.session_alias, s.workspace FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID, ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias) + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace) if err != nil { return nil, err } @@ -1036,7 +1036,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, SELECT 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 + m.status, m.created_at, s.session_alias, s.workspace FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.from_name = $1 AND s.status <> 'archived' @@ -1056,7 +1056,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil { return nil, err } if len(ccJSON) > 0 { diff --git a/gateway/internal/repo/thread.go b/gateway/internal/repo/thread.go index 2381c2a..d0b79da 100644 --- a/gateway/internal/repo/thread.go +++ b/gateway/internal/repo/thread.go @@ -50,7 +50,7 @@ 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, + m.status, m.created_at, s.session_alias, s.workspace, (SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count` // ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。 @@ -202,7 +202,7 @@ func scanTreeRows(rows interface { 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 { + &t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil { return nil, err } if len(ccJSON) > 0 { diff --git a/plugins/homeagent-mail-bridge/schedule.go b/plugins/homeagent-mail-bridge/schedule.go index 40886ac..c2b3720 100644 --- a/plugins/homeagent-mail-bridge/schedule.go +++ b/plugins/homeagent-mail-bridge/schedule.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "strconv" "strings" "time" @@ -103,29 +104,58 @@ func (p *Plugin) handleCreateSchedule(args map[string]interface{}) (interface{}, } // handleListSchedules 列出自己建的日程。 +// +// 查询串必须走 url.Values 编码,**不能手拼**。 +// +// RFC3339 的时区偏移带一个 `+`(2026-12-11T09:00:00+08:00),而 `+` 在查询串里 +// 正是空格的转义形式:手拼进 URL 后服务端 ParseQuery 把它还原成空格, +// time.Parse(RFC3339) 随即失败 —— 而 AgentListCalendarEvents 解析失败时是 +// **静默退回默认区间**(now-24h ~ now+3月),不报错。 +// 于是这里传的 from/to 全部无效,表现为「明明有日程却说一条都没有」。 +// 真实踩过一次:先怀疑了数据库、密钥和权限,最后才发现是这个加号。 func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) { - q := "?status=" + argStringOr(args, "status", "active") + q := url.Values{} + q.Set("status", argStringOr(args, "status", "active")) if v := strings.TrimSpace(argString(args, "from")); v != "" { if t, err := parseEventTime(v); err == nil { - q += "&from=" + t.Format(time.RFC3339) + q.Set("from", t.Format(time.RFC3339)) } } if v := strings.TrimSpace(argString(args, "to")); v != "" { if t, err := parseEventTime(v); err == nil { - q += "&to=" + t.Format(time.RFC3339) + q.Set("to", t.Format(time.RFC3339)) } + } else { + // 默认往后 14 个月,而不是沿用服务端的 3 个月。 + // + // 生日、纪念日这类 yearly / lunar_yearly 的下一次触发随时会落在 + // 3 个月之外。窗口太窄时模型问「我建过什么」得到的是空 —— + // 然后它会照着空结果再建一条重复的。14 个月保证任何年度重复 + // 都至少露一次脸。 + q.Set("to", time.Now().AddDate(1, 2, 0).Format(time.RFC3339)) } var res struct { Events []scheduleEvent `json:"events"` ActiveLimit int `json:"active_limit"` } - if err := p.get(p.gwURL+"/api/v1/agent/calendar/events"+q, &res); err != nil { + if err := p.get(p.gwURL+"/api/v1/agent/calendar/events?"+q.Encode(), &res); err != nil { return nil, err } if len(res.Events) == 0 { - return textResult("你还没有建过日程。用 create_schedule 建一条。"), nil + // 不能说「你还没有建过日程」——窗口外或别的 status 下还有事件时 + // 这是假话,而模型会照着这句话去建一条重复的。 + // 把实际查询区间说出来,让它知道该往哪放宽。 + from := q.Get("from") + if from == "" { + from = "默认起点(昨天)" + } + return textResult(fmt.Sprintf( + "这个区间内没有日程:%s ~ %s,status=%s。\n"+ + "想找更早/更远的请放宽 from/to,或用 status=all 看已暂停与已取消的。\n"+ + "确认真的没有再用 create_schedule 建 —— 别照着空结果建重复的。", + from, q.Get("to"), q.Get("status"))), nil } var sb strings.Builder diff --git a/web/src/components/MailList.tsx b/web/src/components/MailList.tsx index 7292f22..746d58b 100644 --- a/web/src/components/MailList.tsx +++ b/web/src/components/MailList.tsx @@ -5,6 +5,7 @@ import { useUIStore } from '../stores/uiStore'; import type { Mail } from '../types'; import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups'; import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons'; +import { participantAddress } from '../lib/replyTarget'; export default function MailList() { const viewMode = useUIStore(s => s.viewMode); @@ -133,9 +134,15 @@ function SessionGroup({ minute: '2-digit' }); - const peer = showTo - ? `${g.latest.to_name}${g.latest.to_workspace ? '@' + g.latest.to_workspace : ''}` - : `${g.latest.from_name}${g.latest.from_workspace ? '@' + g.latest.from_workspace : ''}`; + // 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了 + // 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。 + // + // workspace 从会话取:from_workspace 对 Agent 存的是 Agent 名而非路径。 + const peerWs = showTo ? g.latest.to_workspace : g.latest.from_workspace; + const peer = participantAddress( + showTo ? g.latest.to_name : g.latest.from_name, + peerWs ? g.latest.session_workspace || '' : '' + ); // 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封 const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false; @@ -233,9 +240,11 @@ function MailItem({ minute: '2-digit' }); - const peer = showTo - ? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}` - : `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`; + const rowWs = showTo ? mail.to_workspace : mail.from_workspace; + const peer = participantAddress( + showTo ? mail.to_name : mail.from_name, + rowWs ? mail.session_workspace || '' : '' + ); return (