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:
2026-09-04 13:49:16 +08:00
parent 390fef8941
commit 55b3f9bc4e
11 changed files with 531 additions and 73 deletions

View File

@ -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

View File

@ -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);
};

View File

@ -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 && (

View File

@ -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 的维度硬套在人身上。
*
* 判据是有没有 workspaceAgent 一定带工作目录,人一定不带。
*
* # 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);
}

View File

@ -100,6 +100,18 @@ export interface Mail {
created_at: string;
hop_limit?: number;
session_alias?: string;
/**
* **这条会话**的工作目录sessions.workspace
*
* 不能用 from_workspace / to_workspace 代替:
* - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
* - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名**
* 而不是路径(历史遗留)
*
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
*/
session_workspace?: string;
body_preview?: string;
attachments?: Attachment[];
}