feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
This commit is contained in:
291
web/src/components/ThreadView.tsx
Normal file
291
web/src/components/ThreadView.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 对话树视图(按方向分块加载)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
*
|
||||
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
|
||||
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
*
|
||||
* 不用 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 [moreUp, setMoreUp] = useState(false);
|
||||
const [moreDown, setMoreDown] = useState(false);
|
||||
const [nextUp, setNextUp] = useState(0);
|
||||
const [nextDown, setNextDown] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinel = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
|
||||
// 读 state 会双双看到 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
const added = incoming.filter(n => !seen.has(n.mail_id));
|
||||
// 按相对深度排;同深度保持服务端给的时间序
|
||||
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { dir: 'around', limit: 40 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
|
||||
setHidden(p.hidden);
|
||||
setMoreUp(p.has_more_up);
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextUp(p.next_up);
|
||||
setNextDown(p.next_down);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (dir: 'up' | 'down') => {
|
||||
if (busy.current) return;
|
||||
if (dir === 'up' && !moreUp) return;
|
||||
if (dir === 'down' && !moreDown) return;
|
||||
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
|
||||
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
|
||||
const el = scrollRef.current;
|
||||
const beforeHeight = el?.scrollHeight ?? 0;
|
||||
const beforeTop = el?.scrollTop ?? 0;
|
||||
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, {
|
||||
dir,
|
||||
offset: dir === 'up' ? nextUp : nextDown,
|
||||
limit: 40
|
||||
});
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
if (dir === 'up') {
|
||||
setMoreUp(p.has_more_up);
|
||||
setNextUp(p.next_up);
|
||||
} else {
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextDown(p.next_down);
|
||||
}
|
||||
if (dir === 'up' && el) {
|
||||
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
},
|
||||
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
|
||||
);
|
||||
|
||||
// 两端各一个哨兵,进入视口就加载对应方向。
|
||||
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
if (e.target === topSentinel.current) loadMore('up');
|
||||
if (e.target === bottomSentinel.current) loadMore('down');
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (topSentinel.current) obs.observe(topSentinel.current);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
|
||||
// 否则祖先未加载时首屏内容会整体缩进一大截。
|
||||
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{(moreUp || moreDown) && ',滑动加载更多'}
|
||||
{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="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-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 ref={topSentinel} className="h-px" />
|
||||
{moreUp && (
|
||||
<button
|
||||
onClick={() => loadMore('up')}
|
||||
className="w-full mb-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 className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{moreDown && (
|
||||
<button
|
||||
onClick={() => loadMore('down')}
|
||||
className="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,
|
||||
baseDepth,
|
||||
anchorID
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
baseDepth: number;
|
||||
anchorID: string;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
|
||||
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div 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" />
|
||||
)}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{node.from_name}
|
||||
{node.from_workspace && `@${node.from_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" />
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user