## 症状
单封邮件的元信息三行都不对(生产实测那封 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)
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
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';
|
||
|
||
/**
|
||
* 对话树视图(从线索根整树展开,分块加载)。
|
||
*
|
||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||
*
|
||
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
|
||
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
|
||
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
|
||
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
|
||
*
|
||
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
|
||
*
|
||
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
|
||
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
|
||
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
|
||
*/
|
||
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
|
||
const [nodes, setNodes] = useState<ThreadNode[]>([]);
|
||
const [hidden, setHidden] = useState(0);
|
||
const [hasMore, setHasMore] = useState(false);
|
||
const [nextOffset, setNextOffset] = useState(0);
|
||
const [err, setErr] = useState('');
|
||
const [initial, setInitial] = useState(true);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||
const anchorRef = useRef<HTMLDivElement>(null);
|
||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||
const gen = useRef(0);
|
||
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
|
||
// 读 state 会看到旧的 false 而并发发两个请求。
|
||
const busy = useRef(false);
|
||
|
||
const sortNodes = (list: ThreadNode[]) =>
|
||
list
|
||
.slice()
|
||
.sort((a, b) =>
|
||
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
|
||
);
|
||
|
||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||
setNodes(prev => {
|
||
const seen = new Set(prev.map(n => n.mail_id));
|
||
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
|
||
});
|
||
}, []);
|
||
|
||
// 首屏
|
||
useEffect(() => {
|
||
const myGen = ++gen.current;
|
||
setNodes([]);
|
||
setHidden(0);
|
||
setErr('');
|
||
setInitial(true);
|
||
busy.current = true;
|
||
api
|
||
.getMailThread(mailID, { offset: 0, limit: 60 })
|
||
.then(p => {
|
||
if (gen.current !== myGen) return;
|
||
setNodes(sortNodes(p.nodes));
|
||
setHidden(p.hidden);
|
||
setHasMore(p.has_more);
|
||
setNextOffset(p.next_offset);
|
||
})
|
||
.catch(e => {
|
||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||
})
|
||
.finally(() => {
|
||
if (gen.current === myGen) {
|
||
setInitial(false);
|
||
busy.current = false;
|
||
}
|
||
});
|
||
}, [mailID]);
|
||
|
||
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
|
||
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
|
||
useEffect(() => {
|
||
if (initial || !anchorRef.current) return;
|
||
anchorRef.current.scrollIntoView({ block: 'center' });
|
||
}, [initial]);
|
||
|
||
const loadMore = useCallback(async () => {
|
||
if (busy.current || !hasMore) return;
|
||
const myGen = gen.current;
|
||
busy.current = true;
|
||
setLoading(true);
|
||
try {
|
||
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
|
||
if (gen.current !== myGen) return;
|
||
merge(p.nodes);
|
||
setHidden(h => h + p.hidden);
|
||
setHasMore(p.has_more);
|
||
setNextOffset(p.next_offset);
|
||
} catch (e) {
|
||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||
} finally {
|
||
if (gen.current === myGen) setLoading(false);
|
||
busy.current = false;
|
||
}
|
||
}, [mailID, merge, hasMore, nextOffset]);
|
||
|
||
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
|
||
// 让加载在用户滑到边界前完成。
|
||
useEffect(() => {
|
||
const root = scrollRef.current;
|
||
if (!root) return;
|
||
const obs = new IntersectionObserver(
|
||
entries => {
|
||
for (const e of entries) if (e.isIntersecting) loadMore();
|
||
},
|
||
{ root, rootMargin: '200px' }
|
||
);
|
||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||
return () => obs.disconnect();
|
||
}, [loadMore]);
|
||
|
||
return (
|
||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
|
||
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
|
||
关闭只收起树、留在这封邮件上。 */}
|
||
<BackButton />
|
||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||
<span className="text-xs text-gray-400">
|
||
已加载 {nodes.length} 封
|
||
{hasMore && ',滑动加载更多'}
|
||
{hidden > 0 && `,${hidden} 封无权查看`}
|
||
</span>
|
||
<div className="flex-1" />
|
||
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||
<button
|
||
onClick={onClose}
|
||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||
>
|
||
<CloseIcon className="w-3.5 h-3.5" />
|
||
关闭
|
||
</button>
|
||
</div>
|
||
|
||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||
{initial && (
|
||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||
加载中
|
||
</div>
|
||
)}
|
||
{err && <p className="text-xs text-red-600">{err}</p>}
|
||
|
||
{!initial && (
|
||
<>
|
||
<div className="space-y-1.5">
|
||
{nodes.map(n => (
|
||
<Node
|
||
key={n.mail_id}
|
||
node={n}
|
||
anchorID={mailID}
|
||
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{hasMore && (
|
||
<button
|
||
onClick={loadMore}
|
||
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||
>
|
||
加载后续往来
|
||
</button>
|
||
)}
|
||
<div ref={bottomSentinel} className="h-px" />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Node({
|
||
node,
|
||
anchorID,
|
||
anchorRef
|
||
}: {
|
||
node: ThreadNode;
|
||
anchorID: string;
|
||
anchorRef?: React.RefObject<HTMLDivElement>;
|
||
}) {
|
||
const openMailByID = useMailStore(s => s.openMailByID);
|
||
const narrow = useIsNarrow();
|
||
const isPermission = node.mail_type === 'permission_request';
|
||
const isAnchor = node.mail_id === anchorID;
|
||
const ccCount = node.cc_list?.length ?? 0;
|
||
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
||
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
||
const isForward = node.subject.startsWith('Fwd: ');
|
||
// 缩进:每级的像素数与上限都随屏宽变。
|
||
//
|
||
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
|
||
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
|
||
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
|
||
const step = narrow ? 10 : 20;
|
||
const maxDepth = narrow ? 5 : 8;
|
||
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
|
||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
return (
|
||
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||
{indent > 0 && (
|
||
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
|
||
)}
|
||
<button
|
||
onClick={() => openMailByID(node.mail_id)}
|
||
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
|
||
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
{node.from_workspace ? (
|
||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||
) : (
|
||
<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">
|
||
{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>
|
||
<div className="flex-1" />
|
||
{isForward && (
|
||
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]">
|
||
转发
|
||
</span>
|
||
)}
|
||
{node.parent_hidden && (
|
||
<span
|
||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
|
||
title="上一封不在你的可见范围内"
|
||
>
|
||
上游不可见
|
||
</span>
|
||
)}
|
||
{isPermission && (
|
||
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
|
||
<ShieldIcon className="w-2.5 h-2.5" />
|
||
权限
|
||
</span>
|
||
)}
|
||
{node.attachment_count > 0 && (
|
||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||
{node.attachment_count}
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||
</div>
|
||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
|
||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||
{ccCount > 0 && (
|
||
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||
抄送 {node.cc_list.map(c => ccAddress(c, node.session_alias)).join('、')}
|
||
</p>
|
||
)}
|
||
{node.body_preview && (
|
||
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||
)}
|
||
{node.session_alias && (
|
||
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|