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:
@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user