## 别名替换(让 .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),未手工拼写
170 lines
6.7 KiB
JavaScript
170 lines
6.7 KiB
JavaScript
/**
|
||
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
|
||
*
|
||
* 两个职责:
|
||
* 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;
|
||
}
|