Files
MailUI4Agents/plugins/dsh-mail-bridge/lib/model-scope.js
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00

170 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
*
* 两个职责:
* 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway
* 2. 按管理员划定的范围决定「先试哪个、再试哪个」
*
* 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider
* 配置、上游上下线、换 API key。只在注册时报的话目录会静静变陈而管理员
* 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型,
* 失败要到真发邮件时才暴露。
*/
/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */
export const MAX_CATALOG = 300;
/**
* 把 opencode 的 `/config/providers` 响应整理成上报格式。
*
* @param {any} config `client.config.providers()` 的结果
* @returns {object[]} `[{ provider, model, display_name }]`
*/
export function snapshotOpencodeModels(config) {
const providers = Array.isArray(config?.providers) ? config.providers : [];
const out = [];
for (const p of providers) {
const provider = typeof p?.id === 'string' ? p.id : '';
if (!provider) continue;
// models 是对象而非数组:键是 model id值是元数据
const models = p?.models && typeof p.models === 'object' ? p.models : {};
for (const [id, meta] of Object.entries(models)) {
if (!id) continue;
out.push({
provider,
model: id,
display_name: typeof meta?.name === 'string' ? meta.name : '',
});
}
}
return dedupeAndCap(out);
}
/**
* 把 DSH 的 provider/model 列表整理成上报格式。
*
* DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`
* 因此这里收的是已经拍平的结果。
*
* @param {any[]} entries `[{ provider, id, name }]`
* @returns {object[]}
*/
export function snapshotDshModels(entries) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 把 pi 的模型列表整理成上报格式。
*
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
*
* pi 的 Model 对象上provider 在 `provider` 字段、模型 id 在 `id` 字段,
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
*
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
* @returns {object[]}
*/
export function snapshotPiModels(models) {
const list = Array.isArray(models) ? models : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*
* 三种情形:
*
* 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级
* 2. **没划定范围**`allowed` 为空)→ 返回 `[undefined]`
* 表示「用平台自己的默认模型试一次」。**不是**空数组:
* 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉,
* 而「管理员没配」的正确含义是不限定。
* 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定,
* 优先于「平台默认」,但**不优先于管理员划定的范围**
* 范围是运行时可改的策略,环境变量是部署时的兜底。
*
* @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank
* @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型
* @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选;
* `undefined` 表示这一次不指定模型、交给平台
*/
export function modelAttemptOrder(allowed, envDefault) {
const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : [];
if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model }));
if (envDefault?.provider && envDefault?.model) {
return [{ provider: envDefault.provider, model: envDefault.model }];
}
return [undefined];
}
/**
* 把多次尝试的失败原因整理成一封邮件正文。
*
* 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息,
* 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
*
* @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败
* @param {string} subject 原邮件主题
* @returns {string} Markdown 正文
*/
export function renderFailureReport(failures, subject) {
const list = Array.isArray(failures) ? failures : [];
const lines = [
`本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`,
'',
`已尝试 ${list.length} 个:`,
'',
];
list.forEach((f, i) => {
const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)';
lines.push(`${i + 1}. **${route}**`);
// 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
});
lines.push('');
lines.push('可能的原因模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。');
lines.push('调整可用模型范围:配置页 → Agent 模型范围。');
return lines.join('\n');
}
/** 去重provider/model 组合)并截断。 */
function dedupeAndCap(list) {
const seen = new Set();
const out = [];
for (const m of list) {
const key = `${m.provider}/${m.model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(m);
if (out.length >= MAX_CATALOG) break;
}
return out;
}