mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-13 08:23:02 +08:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user