/** * 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。 * * 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、 * `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关, * 所以收进这里。各平台只负责把自己的工具定义壳套上去。 * * # 这一组端点解决的问题 * * 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。 * 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从 * 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了 * `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录, * 那个错误路径静默变成了新会话的 workspace。 * * # 渲染的取舍 * * 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到 * `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home * session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。 */ /** * 渲染候选收件人清单(`kind: "name"`)。 * * 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的 * 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。 * 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。 * * @param {string[]} names * @returns {string} */ export function renderNameSuggestions(names) { const list = Array.isArray(names) ? names.filter(Boolean) : []; if (list.length === 0) return '当前没有可投递的收件人。'; return [ `可投递的收件人(${list.length} 个):`, list.map(n => `- ${n}`).join('\n'), '', '下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。', ].join('\n'); } /** * 渲染工作目录候选(`kind: "path"`)。 * * 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录), * 不解释的话模型会卡在这一步,或者编一个路径出来。 * * @param {string[]} paths * @param {string} name 正在查的收件人名,用于拼下一步的提示 * @returns {string} */ export function renderPathSuggestions(paths, name) { const list = Array.isArray(paths) ? paths.filter(Boolean) : []; if (list.length === 0) { return [ `${name} 没有记录在案的工作目录。`, '这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。', `直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`, ].join('\n'); } return [ `${name} 用过的工作目录(按最近使用排序):`, list.map(p => `- ${p}`).join('\n'), '', `下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`, ].join('\n'); } /** * 渲染会话候选(`kind: "session"`)。 * * **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`: * 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。 * * `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型 * 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 * * @param {object} data `/agent/contacts/suggest` 的返回体 * @param {string} name * @param {string} path * @returns {string} */ export function renderSessionSuggestions(data, name, path) { const aliases = Array.isArray(data?.suggestions) ? data.suggestions : []; const addresses = Array.isArray(data?.addresses) ? data.addresses : []; const candidates = Array.isArray(data?.candidates) ? data.candidates : []; // 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话 const existing = aliases.filter(a => a !== 'new'); if (existing.length === 0) { return [ `${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`, `要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`, '并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。', ].join('\n'); } const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`]; for (let i = 0; i < aliases.length; i++) { const alias = aliases[i]; const addr = addresses[i] || ''; const c = candidates[i] || {}; if (alias === 'new') continue; // new 单独放最后 const bits = []; if (c.title) bits.push(c.title); if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`); if (c.source === 'platform') bits.push('平台侧会话'); lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`); } lines.push(''); lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。'); const newAddr = addressAt(addresses, aliases, 'new'); if (newAddr) { lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`); } return lines.join('\n'); } /** 按别名在同序的 addresses 里取地址。 */ function addressAt(addresses, aliases, alias) { const i = aliases.indexOf(alias); return i >= 0 ? addresses[i] || '' : ''; } /** * 渲染会话参与方清单。 * * 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、 * 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数 * 「作为发件人」的邮件,正是为了让这个判断成立。 * * @param {object} data `/agent/sessions/{id}/participants` 的返回体 * @returns {string} */ export function renderParticipants(data) { const parts = Array.isArray(data?.participants) ? data.participants : []; if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。'; const alias = data?.session_alias || ''; const lines = [`会话 #${alias || '未命名'} 的参与方:`]; for (const p of parts) { const tags = []; if (p.is_self) tags.push('就是你'); if (Array.isArray(p.roles) && p.roles.length) { tags.push(p.roles.map(roleLabel).join('/')); } if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应'); const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)'; lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`); } lines.push(''); lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。'); return lines.join('\n'); } /** * 渲染联系人清单(本 Agent 参与过的全部会话)。 * * 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时, * 有未读的那些才是答案。 * * @param {object} data `/agent/contacts` 的返回体 * @param {number} limit 最多列出多少条 * @returns {string} */ export function renderContacts(data, limit = 20) { const list = Array.isArray(data?.contacts) ? data.contacts.slice() : []; if (list.length === 0) return '还没有任何往来会话。'; list.sort((a, b) => { const ua = a?.unread_count || 0; const ub = b?.unread_count || 0; if (ua !== ub) return ub - ua; return String(b?.last_activity || '').localeCompare(String(a?.last_activity || '')); }); const shown = list.slice(0, limit); const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`]; for (const c of shown) { const bits = []; if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`); if (c.subject) bits.push(c.subject); if (c.max_rounds > 0) { const left = Math.max(0, c.max_rounds - (c.used_rounds || 0)); bits.push(`剩 ${left}/${c.max_rounds} 个来回`); } const addr = c.address || '(未命名会话,只能用 reply_to 续谈)'; lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`); } return lines.join('\n'); } /** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ function roleLabel(role) { switch (role) { case 'from': return '发件人'; case 'to': return '收件人'; case 'cc': return '抄送方'; default: return String(role); } } /** * 渲染对话树,回答「谁已经回了、谁还没回」。 * * 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里 * (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。 * * @param {object} data `/agent/mail/{id}/thread` 的返回体 * @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的 * @returns {string} */ export function renderThread(data, selfName = '') { const nodes = Array.isArray(data?.nodes) ? data.nodes : []; if (nodes.length === 0) return '这条线索上没有可见的邮件。'; const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`]; for (const n of nodes) { const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0; const indent = ' '.repeat(Math.min(depth, 8)); const marks = []; if (selfName && n?.from_name === selfName) marks.push('你发的'); if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封'); if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载'); lines.push( `${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` + ` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}` ); } if (data?.has_more) { lines.push(''); lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`); } return lines.join('\n'); }