feat(channels): 统一模型渠道——一处配置,同步 OpenClaw / Hermes / 晴辰助手

新增「模型渠道」页面(通用分组):用户只维护一份渠道配置
(服务商预设 / Base URL / API Key / 模型列表 / 默认模型),
通过显式同步推送到三个消费方,消灭重复配置。

实现要点:
- 渠道存储 src-tauri/src/commands/model_channels.rs:
  读取只回掩码(apiKeySaved/apiKeyMask),写入支持 __KEEP__ 哨兵保留旧 Key,
  明文 Key 仅经 reveal_model_channel_key 在同步时取出;
  文件位于 openclaw 数据目录下,便携迁移自动带走;含 6 个单元测试
- 同步逻辑 src/lib/model-channels.js 组合现有命令完成(各自带自动备份):
  OpenClaw 只 upsert 对应 provider 保留未知字段;
  Hermes 写 provider 环境变量 + 可选默认模型(仅 OpenAI/Anthropic/Gemini
  兼容 API Key 型,OAuth/SDK 型仍走 Hermes 向导);
  助手为一次性拷贝到 localStorage
- 页面 src/pages/model-channels.js:渠道卡片 + 三目标同步状态徽标
  (已同步/有未同步变更/未同步)+ 编辑弹窗(预设自动填充、在线拉取模型、
  默认模型联动)+ 从 OpenClaw 现有配置一键导入 + 全部操作确认弹窗
- Web 模式 dev-api.js 同构 handler + ALWAYS_LOCAL;i18n 简/繁/英 +
  侧栏 11 语言;tests/model-channels.test.js 锁定注册链与安全约束

验证:npm build / cargo fmt+check+clippy -D warnings / cargo test --lib
314 通过 / node --test 全部 427 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
晴天
2026-07-05 11:48:24 -07:00
parent b04deb4e54
commit dba69ee0fc
14 changed files with 1278 additions and 2 deletions

View File

@@ -8778,6 +8778,7 @@ const ALWAYS_LOCAL = new Set([
'assistant_list_dir', 'assistant_system_info', 'assistant_list_processes',
'assistant_check_port', 'assistant_web_search', 'assistant_fetch_url',
'assistant_ensure_data_dir', 'assistant_save_image', 'assistant_load_image', 'assistant_delete_image',
'read_model_channels', 'write_model_channels', 'reveal_model_channel_key',
'read_media_config', 'write_media_config', 'test_media_provider', 'fetch_media_models',
'generate_image', 'create_video_task', 'poll_video_task',
'cancel_media_job', 'list_media_jobs', 'delete_media_job',
@@ -9237,6 +9238,99 @@ function mediaApiKeyMask(key) {
return `${value.slice(0, 3)}***${value.slice(-4)}`
}
// === 统一模型渠道(与 src-tauri/src/commands/model_channels.rs 行为保持一致) ===
const MODEL_CHANNELS_FILE = 'model-channels.json'
function modelChannelsPath() {
return path.join(OPENCLAW_DIR, 'clawpanel', MODEL_CHANNELS_FILE)
}
function isChannelKeepSentinel(key) {
return !key || key === '__KEEP__' || key === '••••••••' || key === '********'
}
function normalizeChannelModel(entry) {
if (typeof entry === 'string') {
const id = entry.trim()
return id ? { id } : null
}
const id = String(entry?.id || '').trim()
if (!id) return null
const out = { id }
const name = String(entry?.name || '').trim()
if (name) out.name = name
const ctx = Number(entry?.contextWindow)
if (Number.isFinite(ctx) && ctx > 0) out.contextWindow = ctx
return out
}
function normalizeModelChannel(entry, current) {
const id = String(entry?.id || '').trim()
const name = String(entry?.name || '').trim()
if (!id || !name) return null
const baseUrl = String(entry?.baseUrl || '').trim().replace(/\/+$/, '')
if (baseUrl && !/^https?:\/\//.test(baseUrl)) return null
const incomingKey = String(entry?.apiKey || '').trim()
const apiKey = isChannelKeepSentinel(incomingKey) ? String(current?.apiKey || '') : incomingKey
const models = []
const seen = new Set()
for (const item of Array.isArray(entry?.models) ? entry.models : []) {
const model = normalizeChannelModel(item)
if (model && !seen.has(model.id)) {
seen.add(model.id)
models.push(model)
}
}
let defaultModel = String(entry?.defaultModel || '').trim()
if (!models.some(m => m.id === defaultModel)) defaultModel = models[0]?.id || ''
return {
id,
name,
presetKey: String(entry?.presetKey || '').trim(),
baseUrl,
apiType: String(entry?.apiType || '').trim() || 'openai-completions',
apiKey,
models,
defaultModel,
enabled: entry?.enabled !== false,
updatedAt: String(entry?.updatedAt || '').trim(),
}
}
function normalizeModelChannelsDoc(config, current) {
const currentChannels = Array.isArray(current?.channels) ? current.channels : []
const channels = []
const seen = new Set()
for (const entry of (Array.isArray(config?.channels) ? config.channels : []).slice(0, 100)) {
const id = String(entry?.id || '').trim()
if (seen.has(id)) continue
seen.add(id)
const normalized = normalizeModelChannel(entry, currentChannels.find(c => c.id === id))
if (normalized) channels.push(normalized)
}
const syncState = config?.syncState && typeof config.syncState === 'object' ? config.syncState : {}
return { version: 1, channels, syncState }
}
function readModelChannelsPrivate() {
return normalizeModelChannelsDoc(
readJsonOrDefault(modelChannelsPath(), { version: 1, channels: [], syncState: {} }),
null,
)
}
function sanitizeModelChannelsForRead(doc) {
return {
...doc,
channels: doc.channels.map(ch => ({
...ch,
apiKey: '',
apiKeySaved: Boolean(String(ch.apiKey || '').trim()),
apiKeyMask: mediaApiKeyMask(ch.apiKey),
})),
}
}
function mediaProviderIds() {
return [MEDIA_PROVIDER_VOLCENGINE, MEDIA_PROVIDER_OPENAI, MEDIA_PROVIDER_NEWAPI]
}
@@ -12938,6 +13032,24 @@ const handlers = {
return null
},
// 统一模型渠道
read_model_channels() {
return sanitizeModelChannelsForRead(readModelChannelsPrivate())
},
write_model_channels({ config }) {
const normalized = normalizeModelChannelsDoc(config, readModelChannelsPrivate())
writeJsonAtomic(modelChannelsPath(), normalized)
return sanitizeModelChannelsForRead(normalized)
},
reveal_model_channel_key({ channelId }) {
const doc = readModelChannelsPrivate()
const channel = doc.channels.find(ch => ch.id === String(channelId || '').trim())
if (!channel) throw new Error(`模型渠道不存在: ${channelId}`)
return channel.apiKey || ''
},
// 云端媒体生成
read_media_config() {
ensureMediaRoot()