fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状
单封邮件的元信息三行都不对(生产实测那封 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)
This commit is contained in:
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
|
||||
@ -9,7 +9,9 @@ import {
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
mailCounterpart,
|
||||
replyAllCC
|
||||
replyAllCC,
|
||||
participantAddress,
|
||||
ccAddress
|
||||
} from '../lib/replyTarget';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
@ -367,10 +369,29 @@ function Header({
|
||||
onThread: () => void;
|
||||
}) {
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
|
||||
mail.session_alias ? '.' + mail.session_alias : ''
|
||||
}`;
|
||||
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
|
||||
|
||||
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
||||
//
|
||||
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
|
||||
// 人 → `jianf` 人没有目录也不需要会话位
|
||||
//
|
||||
// 此前这里有两处错:别名被拼给了**发件人**(`jianf.<别名>` —— 既指错归属,
|
||||
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
|
||||
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
|
||||
//
|
||||
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
|
||||
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
|
||||
const ws = mail.session_workspace || '';
|
||||
const from = participantAddress(
|
||||
mail.from_name,
|
||||
mail.from_workspace ? ws : '',
|
||||
mail.session_alias
|
||||
);
|
||||
const to = participantAddress(
|
||||
mail.to_name,
|
||||
mail.to_workspace ? ws : '',
|
||||
mail.session_alias
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
|
||||
@ -415,7 +436,11 @@ function Header({
|
||||
<Row label="发件">{from}</Row>
|
||||
<Row label="收件">{to}</Row>
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<Row label="抄送">{mail.cc_list.map(a => a.raw).join('、')}</Row>
|
||||
// 抄送走 ccAddress:把一次性的 `.new` 换成真实会话别名。
|
||||
// 留着 `.new` 会让人以为再发一次还能投进同一条会话,实际会开新的。
|
||||
<Row label="抄送">
|
||||
{mail.cc_list.map(a => ccAddress(a, mail.session_alias)).join('、')}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="时间">{time}</Row>
|
||||
</dl>
|
||||
@ -427,7 +452,8 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
||||
<dd className="font-mono text-gray-600 break-all">{children}</dd>
|
||||
{/* min-w-0 + break-all:会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
|
||||
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -467,7 +493,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400"
|
||||
title={mail.cc_list.map(c => c.raw || c.name).join(', ')}
|
||||
title={mail.cc_list.map(c => ccAddress(c, mail.session_alias)).join(', ')}
|
||||
>
|
||||
抄送 {mail.cc_list.length}
|
||||
</span>
|
||||
@ -628,13 +654,14 @@ function ReplyBar({
|
||||
/**
|
||||
* 「回复全部」:把原邮件的其他参与方填进抄送。
|
||||
*
|
||||
* cc_list 是结构化的 Address(后端解析过三维寻址),取 raw 回填 ——
|
||||
* 那是用户当初写下的原文,重新拼 name@path 会丢掉会话段。
|
||||
* cc_list 是结构化的 Address(后端解析过三维寻址)。传当前会话别名进去,
|
||||
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
|
||||
* **另开一条新会话**,于是同一件事裂成两条线索。
|
||||
*/
|
||||
const replyAll = () => {
|
||||
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
||||
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
||||
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
|
||||
setCc(replyAllCC(replyTo, me, peer.name, replyTo.session_alias).join(', '));
|
||||
setCcOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
import { participantAddress, ccAddress } from '../lib/replyTarget';
|
||||
import BackButton from './BackButton';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
@ -236,9 +237,14 @@ function Node({
|
||||
) : (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
|
||||
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
|
||||
workspace 从会话取(from_workspace 对 Agent 存的是 Agent 名)。 */}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{node.from_name}
|
||||
{node.from_workspace && `@${node.from_workspace}`}
|
||||
{participantAddress(
|
||||
node.from_name,
|
||||
node.from_workspace ? node.session_workspace || '' : ''
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">→</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
@ -275,7 +281,7 @@ function Node({
|
||||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||||
{ccCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
抄送 {node.cc_list.map(c => c.raw || c.name).join('、')}
|
||||
抄送 {node.cc_list.map(c => ccAddress(c, node.session_alias)).join('、')}
|
||||
</p>
|
||||
)}
|
||||
{node.body_preview && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Mail, Session } from '../types';
|
||||
import type { Address, Mail, Session } from '../types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
@ -28,11 +28,64 @@ export interface Counterpart {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** 拼三维地址 `name@path.session`。session 为空时省略该段。 */
|
||||
/**
|
||||
* 拼三维地址 `name@path.session`。**逐字节对齐后端 `models.FormatAddress`。**
|
||||
*
|
||||
* 三个分支缺一不可:
|
||||
*
|
||||
* session 为空 + path 为空 → `jianf` (裸名字 = 默认会话)
|
||||
* session 为空 + 有 path → `pi@/home`
|
||||
* session 非空 → `pi@/home.任务` / `jianf@.任务`
|
||||
*
|
||||
* **最后一个分支在 path 为空时仍要保留 `@`。**
|
||||
* 地址按**最后一个 `.`** 切分:`jianf@.任务` 能正确还原成
|
||||
* name=`jianf` / path=`` / session=`任务`,而漏掉 `@` 的 `jianf.任务`
|
||||
* 会被整串当成**名字**(实测 ParseAddress 返回 name="jianf.任务")——
|
||||
* 那是个不存在的 Agent,投递必然 404。
|
||||
*
|
||||
* 人类没有工作目录,所以 path 为空是界面上的常态而非边界情形:
|
||||
* 给人类回信时若丢掉 `@`,整条地址就废了。
|
||||
*
|
||||
* 展示用途请走 `participantAddress` —— 它按「人 / Agent」决定带几段。
|
||||
*/
|
||||
export function formatAddress(name: string, path: string, session?: string | null): string {
|
||||
if (!name) return '';
|
||||
const base = `${name}@${path || ''}`;
|
||||
return session ? `${base}.${session}` : base;
|
||||
const n = (name || '').trim();
|
||||
if (!n) return '';
|
||||
const p = (path || '').trim();
|
||||
const s = (session || '').trim();
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个参与方在**这封邮件所属会话**里的完整地址。
|
||||
*
|
||||
* # 人与 Agent 的地址维度不同
|
||||
*
|
||||
* **Agent 要三段**:`name@path.session` 才唯一确定「哪个 Agent、在哪个目录、
|
||||
* 哪条线索」。同名 Agent 在不同目录是不同的活,同一目录下不同会话是不同的任务
|
||||
* —— 少任何一段都不是个可投递的地址。
|
||||
*
|
||||
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
|
||||
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
|
||||
*
|
||||
* 判据是有没有 workspace:Agent 一定带工作目录,人一定不带。
|
||||
*
|
||||
* # workspace 必须从**会话**取,不能用 from_workspace
|
||||
*
|
||||
* `mails.from_workspace` 对 Agent 存的是**Agent 名而不是路径**(历史遗留,
|
||||
* 见后端 db/migrate.go 里 sessions.workspace 的注释)。拿它当路径拼会得到
|
||||
* `dsh@dsh` —— 界面上真出现过。会话的 `workspace` 才是权威来源。
|
||||
*/
|
||||
export function participantAddress(
|
||||
name: string,
|
||||
workspace?: string | null,
|
||||
sessionAlias?: string | null
|
||||
): string {
|
||||
const path = (workspace || '').trim();
|
||||
// 人(无工作目录):只有名字,不带 path 也不带会话位
|
||||
if (!path) return formatAddress(name, '');
|
||||
return formatAddress(name, path, sessionAlias || null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -121,12 +174,17 @@ export function mailReplyTarget(mail: Mail, me: string): string {
|
||||
* 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据,
|
||||
* 结果是点「回复全部」会把自己抄送进去。
|
||||
*/
|
||||
export function replyAllCC(mail: Mail, me: string, primaryName: string): string[] {
|
||||
export function replyAllCC(
|
||||
mail: Mail,
|
||||
me: string,
|
||||
primaryName: string,
|
||||
currentAlias?: string | null
|
||||
): string[] {
|
||||
const raw = [
|
||||
formatAddress(mail.from_name, mail.from_workspace || ''),
|
||||
formatAddress(mail.to_name, mail.to_workspace || ''),
|
||||
// cc_list 取 raw:那是用户当初写下的原文,重新拼会丢掉会话段
|
||||
...(mail.cc_list ?? []).map(c => c.raw || c.name)
|
||||
// cc_list 走 ccAddress:它把一次性的 `.new` 换成真实会话别名
|
||||
...(mail.cc_list ?? []).map(c => ccAddress(c, currentAlias))
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
@ -143,3 +201,24 @@ export function replyAllCC(mail: Mail, me: string, primaryName: string): string[
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个抄送地址的展示/投递形式,把 `.new` 换成真实会话别名。
|
||||
*
|
||||
* `.new` 只在**发信那一刻**有意义:它建完会话就用完了。存档后继续显示
|
||||
* `pi@/x.new` 会让人以为再发一次还能投进同一条会话,实际会开出第三条 ——
|
||||
* 与插件侧 SSE 事件把 `.new` 换成真别名(`reply_address`)同一道理。
|
||||
*
|
||||
* 不知道真别名时省略会话位而不是保留 `.new`:`name@path` 至少指向默认会话,
|
||||
* 而 `.new` 一定会建新的。
|
||||
*/
|
||||
export function ccAddress(cc: Address, currentAlias?: string | null): string {
|
||||
if (!cc?.name) return cc?.raw || '';
|
||||
// 人(无 path)只显示名字 —— 与 participantAddress 同一判据。
|
||||
// 抄送给人时地址里出现会话位是把 Agent 的维度套在人身上。
|
||||
if (!(cc.path || '').trim()) return cc.name;
|
||||
if ((cc.session || '').trim() === 'new') {
|
||||
return formatAddress(cc.name, cc.path || '', currentAlias || null);
|
||||
}
|
||||
return cc.raw || formatAddress(cc.name, cc.path || '', cc.session || null);
|
||||
}
|
||||
|
||||
@ -100,6 +100,18 @@ export interface Mail {
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
/**
|
||||
* **这条会话**的工作目录(sessions.workspace)。
|
||||
*
|
||||
* 不能用 from_workspace / to_workspace 代替:
|
||||
* - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
* - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名**
|
||||
* 而不是路径(历史遗留)
|
||||
*
|
||||
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
|
||||
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
|
||||
*/
|
||||
session_workspace?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
@ -5,9 +5,11 @@ import {
|
||||
sessionCounterpart,
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
replyAllCC
|
||||
replyAllCC,
|
||||
participantAddress,
|
||||
ccAddress
|
||||
} from '../../src/lib/replyTarget';
|
||||
import type { Mail, Session } from '../../src/types';
|
||||
import type { Address, Mail, Session } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
@ -62,8 +64,13 @@ describe('formatAddress', () => {
|
||||
expect(formatAddress('pi', '/home')).toBe('pi@/home');
|
||||
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
|
||||
});
|
||||
it('path 为空仍保留 @(人类没有工作目录)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf@');
|
||||
it('path 与 session 都为空时返回裸名字(裸名字 = 默认会话)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
});
|
||||
it('path 为空但有 session 时必须保留 @', () => {
|
||||
// `jianf@.任务` 能被 ParseAddress 还原(按最后一个 . 切分);
|
||||
// 漏掉 @ 的 `jianf.任务` 会被整串当成名字 —— 那是个不存在的 Agent
|
||||
expect(formatAddress('jianf', '', '任务')).toBe('jianf@.任务');
|
||||
});
|
||||
it('名字为空给空串而不是拼出 @', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
@ -239,3 +246,180 @@ describe('replyAllCC', () => {
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ─── 地址展示 ───
|
||||
*
|
||||
* 锁的是一次生产事故:单封邮件视图的「发件」行显示成
|
||||
* jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
|
||||
* 而人指定的收件方是 pi@/home/program/agentmail 的那条会话。三处错叠在一起:
|
||||
*
|
||||
* 1. 会话别名被拼给了**发件人** —— 别名是会话的属性、两端共有,不属于任何一方
|
||||
* 2. from_workspace 为空(人类没有工作目录)时拼出 `jianf.<别名>`,
|
||||
* 按三维寻址「最后一个 . 切分」规则,那是 path 位为空的**非法地址**
|
||||
* 3. 抄送显示 `pi@/home/program/agentmail.new` —— `.new` 建完会话就失效了,
|
||||
* 留着会让人以为再发一次还能投进同一条会话
|
||||
*/
|
||||
describe('formatAddress 边界', () => {
|
||||
it('path 为空时保留 @,不能拼成 name.session', () => {
|
||||
// bug 现场:jianf 没有工作目录,界面拼出了 `jianf.某会话别名`。
|
||||
// 那会被 ParseAddress 整串当成名字(实测 name="jianf.某会话别名")——
|
||||
// 一个不存在的 Agent。正确形式是 `jianf@.某会话别名`。
|
||||
expect(formatAddress('jianf', '', '某会话别名')).toBe('jianf@.某会话别名');
|
||||
expect(formatAddress('jianf', '', '某会话别名')).not.toBe('jianf.某会话别名');
|
||||
});
|
||||
|
||||
it('无 session 时 path 为空返回裸名字(不留孤零零的 @)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
expect(formatAddress('jianf', ' ')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('name 为空返回空串而不是残缺地址', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
expect(formatAddress(' ', '/home', 'x')).toBe('');
|
||||
});
|
||||
|
||||
it('三段齐全时正常拼接', () => {
|
||||
expect(formatAddress('pi', '/home/program/agentmail', 'my-task'))
|
||||
.toBe('pi@/home/program/agentmail.my-task');
|
||||
});
|
||||
|
||||
it('去首尾空白', () => {
|
||||
expect(formatAddress(' pi ', ' /home ', ' t ')).toBe('pi@/home.t');
|
||||
});
|
||||
|
||||
it('path 含 . 与 / 时仍按最后一个 . 切分(与后端同构)', () => {
|
||||
// 拼出来的地址必须能被后端 ParseAddress 还原
|
||||
const addr = formatAddress('pi', '/home/a.b/c', 'sess');
|
||||
expect(addr).toBe('pi@/home/a.b/c.sess');
|
||||
expect(addr.slice(addr.lastIndexOf('.') + 1)).toBe('sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantAddress', () => {
|
||||
/**
|
||||
* 人与 Agent 的地址维度不同:
|
||||
* Agent 要三段(name@path.session)才唯一确定「哪个 Agent、哪个目录、哪条线索」
|
||||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||||
*/
|
||||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||||
expect(participantAddress('pi', '/home/program/agentmail', '我的任务'))
|
||||
.toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||||
expect(participantAddress('pi', '/home/program/agentmail', null))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
expect(participantAddress('pi', '/home/program/agentmail'))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||||
expect(participantAddress('jianf', '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('人的地址里不含 @ 也不含 .', () => {
|
||||
const addr = participantAddress('jianf', '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
expect(addr).toBe('jianf');
|
||||
expect(addr).not.toContain('@');
|
||||
expect(addr).not.toContain('.');
|
||||
});
|
||||
|
||||
it('workspace 只有空白时按人处理', () => {
|
||||
expect(participantAddress('jianf', ' ', '某会话')).toBe('jianf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ccAddress', () => {
|
||||
const cc = (over: Partial<Address> = {}): Address => ({
|
||||
name: 'pi',
|
||||
path: '/home/program/agentmail',
|
||||
session: '',
|
||||
raw: 'pi@/home/program/agentmail',
|
||||
...over
|
||||
});
|
||||
|
||||
it('.new 换成当前会话别名', () => {
|
||||
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
|
||||
expect(ccAddress(a, '我的任务')).toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('不知道真别名时省略会话位,不保留 .new', () => {
|
||||
const a = cc({ session: 'new', raw: 'pi@/home/program/agentmail.new' });
|
||||
// `name@path` 至少指向默认会话;`.new` 一定会建新的
|
||||
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail');
|
||||
expect(ccAddress(a)).toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('已有具体别名时原样保留(那是用户写下的原文)', () => {
|
||||
const a = cc({ session: 'other-task', raw: 'pi@/home.other-task' });
|
||||
expect(ccAddress(a, '我的任务')).toBe('pi@/home.other-task');
|
||||
});
|
||||
|
||||
it('无 raw 时按结构化字段重拼', () => {
|
||||
const a = cc({ session: 'x', raw: '' });
|
||||
expect(ccAddress(a, null)).toBe('pi@/home/program/agentmail.x');
|
||||
});
|
||||
|
||||
it('name 缺失时退回 raw 而不是抛错', () => {
|
||||
expect(ccAddress({ name: '', path: '', session: '', raw: '原文' } as Address)).toBe('原文');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC 的 .new 处理', () => {
|
||||
it('抄送里的 .new 被换成当前会话别名', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
cc_list: [{
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
}]
|
||||
});
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— 且 .new 已换成真别名
|
||||
expect(replyAllCC(m, 'jianf', 'pi', '当前任务')).toEqual(['dsh@/opt.当前任务']);
|
||||
});
|
||||
|
||||
it('不传别名时抄送退到默认会话而不是 .new', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
session_alias: '某个很长的会话别名'
|
||||
});
|
||||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||||
expect(replyAllCC(m, 'dsh', 'pi', '某个很长的会话别名')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ccAddress 对人与 Agent 的分流', () => {
|
||||
it('抄送给人时只显示名字(不带会话位)', () => {
|
||||
const human = { name: 'jianf', path: '', session: '', raw: 'jianf' } as Address;
|
||||
expect(ccAddress(human, '某会话')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('抄送给 Agent 时带完整三段', () => {
|
||||
const agent = {
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
} as Address;
|
||||
expect(ccAddress(agent, '当前任务')).toBe('dsh@/opt.当前任务');
|
||||
});
|
||||
});
|
||||
|
||||
98
web/test/manual/addr-verify.mjs
Normal file
98
web/test/manual/addr-verify.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 邮件详情页地址行的真实渲染验收。
|
||||
*
|
||||
* 锁的是那次事故:发件行显示成 `jianf.<会话别名>` —— 别名拼给了发件人,
|
||||
* 而且 path 为空时拼出了 ParseAddress 会整串当成名字的非法形态。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page } = await openApp(WIDE);
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) console.log(` 通过 ${name}`);
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
// 走**发件**箱:报这个 bug 的那封信是人发出去的(jianf → pi,抄送 pi@….new),
|
||||
// 收件箱里只有 Agent 的回信 —— 而回信没有抄送、也不带 `.new`,
|
||||
// 探不到要验的那三处。
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
await page.click('button:has-text("发件")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 收件箱/发件箱按会话分组:先点组头展开,再点里面的邮件行。
|
||||
// 两者 className 都含 `w-full text-left`,靠「点完有没有出现含『发件』的 dl」区分。
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
const t = await b.innerText().catch(() => '');
|
||||
if (!t) continue;
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(350);
|
||||
const hasMeta = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some(dl => dl.textContent.includes('发件')));
|
||||
if (hasMeta) { opened = true; break; }
|
||||
}
|
||||
}
|
||||
check('打开了一封邮件', opened);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 读元信息 dl
|
||||
const meta = await page.evaluate(() => {
|
||||
const dls = [...document.querySelectorAll('dl')];
|
||||
for (const dl of dls) {
|
||||
const pairs = [];
|
||||
const kids = [...dl.children];
|
||||
for (const div of kids) {
|
||||
const dt = div.querySelector('dt'), dd = div.querySelector('dd');
|
||||
if (dt && dd) pairs.push([dt.textContent.trim(), dd.textContent.trim()]);
|
||||
}
|
||||
if (pairs.some(([k]) => k === '发件')) return Object.fromEntries(pairs);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!meta) {
|
||||
check('找到元信息区', false, '没有含「发件」的 dl');
|
||||
} else {
|
||||
console.log(' 元信息:', JSON.stringify(meta));
|
||||
const from = meta['发件'] || '';
|
||||
const to = meta['收件'] || '';
|
||||
const cc = meta['抄送'] || '';
|
||||
|
||||
// 判「哪一方是人」:人的地址里既无 @ 也无 .
|
||||
const isHuman = a => a && !a.includes('@') && !a.includes('.');
|
||||
const human = isHuman(from) ? from : (isHuman(to) ? to : '');
|
||||
const agent = isHuman(from) ? to : from;
|
||||
|
||||
check('有一方是人(裸名字,无 @ 无 .)', !!human, `发件=${from} 收件=${to}`);
|
||||
check('人的地址不含会话位', !human || !/[@.]/.test(human), `人=${human}`);
|
||||
|
||||
// Agent 必须三段齐全:name@path.session
|
||||
const threeSeg = /^[^@]+@\/[^@]*\.[^.@]+$/.test(agent);
|
||||
check('Agent 带完整三段 name@path.session', threeSeg, `agent=${agent}`);
|
||||
|
||||
// path 不能是 Agent 名(`dsh@dsh` 那个 bug)
|
||||
if (agent.includes('@')) {
|
||||
const [n, rest] = [agent.slice(0, agent.indexOf('@')), agent.slice(agent.indexOf('@') + 1)];
|
||||
check('Agent 的 path 是真路径而不是 Agent 名',
|
||||
rest.startsWith('/'), `${n}@${rest}`);
|
||||
}
|
||||
|
||||
// 别名不能挂在人身上
|
||||
check('会话别名跟着 Agent 而不是人',
|
||||
!human || !agent || agent.includes('.'), `人=${human} agent=${agent}`);
|
||||
|
||||
// 抄送里不能残留 .new
|
||||
check('抄送里没有 .new',
|
||||
!cc.split('、').some(a => a.trim().endsWith('.new')), `抄送=${cc}`);
|
||||
if (cc) console.log(` 抄送实际值: ${cc}`);
|
||||
|
||||
// 不该再有独立的「会话」行(别名已在 Agent 地址里)
|
||||
check('没有多余的「会话」行', !('会话' in meta), `会话=${meta['会话']}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user