diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8aa34..0b0691f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [未发布 (Unreleased)] + +### 新功能 (Features) + +- **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置 + - 同步为显式推送并逐项确认:写入 OpenClaw 只更新对应 provider 且保留未知字段(自动备份);写入 Hermes 走环境变量与配置命令(自动备份);同步助手为一次性拷贝 + - 支持从 OpenClaw 现有模型配置一键导入渠道、从服务商在线拉取模型列表、渠道修改后的「有未同步变更」提醒 + - 渠道文件存放在 OpenClaw 数据目录下(便携迁移自动带走);密钥读取只返回掩码,写入支持保留旧 Key + - Hermes 同步覆盖 OpenAI / Anthropic / Gemini 兼容接口(API Key 型);OAuth / 云 SDK 型服务商仍走 Hermes 安装向导 + ## [0.18.6] - 2026-07-04 ### 新功能 (Features) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4fac0ed..6ef0dcb 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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() diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bd33b36..ae8470d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod logs; pub mod media; pub mod memory; pub mod messaging; +pub mod model_channels; pub mod pairing; pub mod portable; pub mod service; diff --git a/src-tauri/src/commands/model_channels.rs b/src-tauri/src/commands/model_channels.rs new file mode 100644 index 0000000..22f0779 --- /dev/null +++ b/src-tauri/src/commands/model_channels.rs @@ -0,0 +1,319 @@ +//! 统一模型渠道(Model Channels) +//! +//! 用户只维护一份「渠道 = Base URL + API Key + 可用模型列表」配置, +//! 由前端组合现有命令(write_openclaw_config / hermes_env_set / +//! hermes_model_config_save / localStorage)显式同步到 OpenClaw、Hermes 与晴辰助手。 +//! +//! 本模块只负责渠道存储: +//! - 读取接口对 API Key 永远只返回掩码(apiKeySaved + apiKeyMask); +//! - 写入支持 `__KEEP__` / 空值哨兵保留旧 Key(与创作中心一致); +//! - 明文 Key 仅通过 reveal_model_channel_key 在同步时按渠道单独取出 +//! (先例:hermes_env_reveal)。 +//! +//! 存储位置:openclaw_dir/clawpanel/model-channels.json —— 跟随 OpenClaw +//! 数据目录,便携迁移整体复制后自动生效(与媒体数据同一决策)。 + +use serde_json::{json, Map, Value}; +use std::path::PathBuf; + +const CHANNELS_FILE: &str = "model-channels.json"; +/// 渠道数量上限:防呆,不是产品限制 +const MAX_CHANNELS: usize = 100; + +fn channels_path() -> PathBuf { + super::openclaw_dir().join("clawpanel").join(CHANNELS_FILE) +} + +fn default_channels_doc() -> Value { + json!({ "version": 1, "channels": [], "syncState": {} }) +} + +fn str_of(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string() +} + +fn is_keep_sentinel(key: &str) -> bool { + key.is_empty() || key == "__KEEP__" || key == "••••••••" || key == "********" +} + +/// 归一化单个模型条目:接受字符串或 { id, name? } 对象,id 为空则丢弃 +fn normalize_model_entry(entry: &Value) -> Option { + if let Some(id) = entry.as_str() { + let id = id.trim(); + if id.is_empty() { + return None; + } + return Some(json!({ "id": id })); + } + let id = str_of(entry, "id"); + if id.is_empty() { + return None; + } + let mut out = Map::new(); + out.insert("id".into(), Value::String(id)); + let name = str_of(entry, "name"); + if !name.is_empty() { + out.insert("name".into(), Value::String(name)); + } + if let Some(ctx) = entry.get("contextWindow").and_then(Value::as_u64) { + out.insert("contextWindow".into(), Value::Number(ctx.into())); + } + Some(Value::Object(out)) +} + +/// 归一化单个渠道;current 为同 id 的旧渠道(用于保留旧 Key)。 +/// 返回 None 表示条目非法(缺 id/名称),直接丢弃。 +fn normalize_channel(entry: &Value, current: Option<&Value>) -> Option { + let id = str_of(entry, "id"); + let name = str_of(entry, "name"); + if id.is_empty() || name.is_empty() { + return None; + } + let base_url = str_of(entry, "baseUrl").trim_end_matches('/').to_string(); + if !(base_url.is_empty() || base_url.starts_with("https://") || base_url.starts_with("http://")) + { + return None; + } + + let incoming_key = str_of(entry, "apiKey"); + let api_key = if is_keep_sentinel(&incoming_key) { + current.map(|c| str_of(c, "apiKey")).unwrap_or_default() + } else { + incoming_key + }; + + let mut models: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(arr) = entry.get("models").and_then(Value::as_array) { + for item in arr { + if let Some(model) = normalize_model_entry(item) { + let model_id = str_of(&model, "id"); + if seen.insert(model_id) { + models.push(model); + } + } + } + } + + let default_model = str_of(entry, "defaultModel"); + let default_model = + if !default_model.is_empty() && models.iter().any(|m| str_of(m, "id") == default_model) { + default_model + } else { + // 默认模型必须在列表内;否则取第一个 + models.first().map(|m| str_of(m, "id")).unwrap_or_default() + }; + + let api_type = { + let t = str_of(entry, "apiType"); + if t.is_empty() { + "openai-completions".to_string() + } else { + t + } + }; + + Some(json!({ + "id": id, + "name": name, + "presetKey": str_of(entry, "presetKey"), + "baseUrl": base_url, + "apiType": api_type, + "apiKey": api_key, + "models": models, + "defaultModel": default_model, + "enabled": entry.get("enabled").and_then(Value::as_bool).unwrap_or(true), + "updatedAt": str_of(entry, "updatedAt"), + })) +} + +/// 归一化整个文档;current 提供旧文档以支持保留旧 Key +fn normalize_channels_doc(config: &Value, current: Option<&Value>) -> Value { + let empty = Vec::new(); + let current_channels = current + .and_then(|c| c.get("channels")) + .and_then(Value::as_array) + .unwrap_or(&empty); + let find_current = |id: &str| current_channels.iter().find(|c| str_of(c, "id") == id); + + let mut channels = Vec::new(); + if let Some(arr) = config.get("channels").and_then(Value::as_array) { + let mut seen = std::collections::HashSet::new(); + for entry in arr.iter().take(MAX_CHANNELS) { + let id = str_of(entry, "id"); + if !seen.insert(id.clone()) { + continue; + } + if let Some(ch) = normalize_channel(entry, find_current(&id)) { + channels.push(ch); + } + } + } + + let sync_state = config + .get("syncState") + .filter(|v| v.is_object()) + .cloned() + .unwrap_or_else(|| json!({})); + + json!({ "version": 1, "channels": channels, "syncState": sync_state }) +} + +fn read_channels_private() -> Value { + let parsed = super::read_json_file_content(&channels_path()) + .and_then(|content| serde_json::from_str::(&content).ok()) + .unwrap_or_else(default_channels_doc); + normalize_channels_doc(&parsed, None) +} + +/// 对外读取:API Key 只回掩码 +fn sanitize_doc_for_read(doc: &Value) -> Value { + let mut out = doc.clone(); + if let Some(channels) = out.get_mut("channels").and_then(Value::as_array_mut) { + for channel in channels { + if let Some(obj) = channel.as_object_mut() { + let api_key = obj + .get("apiKey") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + obj.insert("apiKey".into(), Value::String(String::new())); + obj.insert( + "apiKeySaved".into(), + Value::Bool(!api_key.trim().is_empty()), + ); + obj.insert( + "apiKeyMask".into(), + Value::String(super::media::api_key_mask(&api_key)), + ); + } + } + } + out +} + +#[tauri::command] +pub fn read_model_channels() -> Result { + Ok(sanitize_doc_for_read(&read_channels_private())) +} + +#[tauri::command] +pub fn write_model_channels(config: Value) -> Result { + let current = read_channels_private(); + let normalized = normalize_channels_doc(&config, Some(¤t)); + super::media::write_json_atomic(&channels_path(), &normalized)?; + Ok(sanitize_doc_for_read(&normalized)) +} + +/// 明文 Key 仅在同步 / 助手拷贝时按渠道取出,不进入常规读取链路 +#[tauri::command] +pub fn reveal_model_channel_key(channel_id: String) -> Result { + let doc = read_channels_private(); + let channels = doc + .get("channels") + .and_then(Value::as_array) + .ok_or_else(|| "渠道配置格式错误".to_string())?; + let channel = channels + .iter() + .find(|c| str_of(c, "id") == channel_id.trim()) + .ok_or_else(|| format!("模型渠道不存在: {channel_id}"))?; + Ok(str_of(channel, "apiKey")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_channel(id: &str, key: &str) -> Value { + json!({ + "id": id, + "name": "测试渠道", + "presetKey": "openai", + "baseUrl": "https://api.openai.com/v1/", + "apiType": "openai-completions", + "apiKey": key, + "models": ["gpt-4o", { "id": "gpt-4o-mini", "name": "Mini" }, "", "gpt-4o"], + "defaultModel": "gpt-4o-mini" + }) + } + + #[test] + fn normalize_trims_and_dedups_models() { + let doc = normalize_channels_doc( + &json!({ "channels": [sample_channel("ch-1", "sk-abcdefgh1234")] }), + None, + ); + let ch = &doc["channels"][0]; + assert_eq!(ch["baseUrl"], "https://api.openai.com/v1"); + let models = ch["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["id"], "gpt-4o"); + assert_eq!(models[1]["name"], "Mini"); + // 默认模型在列表内则保留 + assert_eq!(ch["defaultModel"], "gpt-4o-mini"); + assert_eq!(ch["enabled"], true); + } + + #[test] + fn keep_sentinel_preserves_old_key() { + let current = normalize_channels_doc( + &json!({ "channels": [sample_channel("ch-1", "sk-real-key-123")] }), + None, + ); + for sentinel in ["", "__KEEP__", "••••••••", "********"] { + let incoming = json!({ "channels": [sample_channel("ch-1", sentinel)] }); + let merged = normalize_channels_doc(&incoming, Some(¤t)); + assert_eq!( + merged["channels"][0]["apiKey"], "sk-real-key-123", + "sentinel {sentinel:?} 应保留旧 Key" + ); + } + // 新 Key 覆盖旧 Key + let incoming = json!({ "channels": [sample_channel("ch-1", "sk-new")] }); + let merged = normalize_channels_doc(&incoming, Some(¤t)); + assert_eq!(merged["channels"][0]["apiKey"], "sk-new"); + } + + #[test] + fn read_sanitization_masks_key() { + let doc = normalize_channels_doc( + &json!({ "channels": [sample_channel("ch-1", "sk-abcdefgh1234")] }), + None, + ); + let public = sanitize_doc_for_read(&doc); + let ch = &public["channels"][0]; + assert_eq!(ch["apiKey"], ""); + assert_eq!(ch["apiKeySaved"], true); + assert_eq!(ch["apiKeyMask"], "sk-***1234"); + } + + #[test] + fn invalid_entries_are_dropped() { + let doc = normalize_channels_doc( + &json!({ "channels": [ + { "name": "缺 id" }, + { "id": "ch-2", "name": "" }, + { "id": "ch-3", "name": "非法地址", "baseUrl": "ftp://x" }, + sample_channel("ch-4", "k"), + sample_channel("ch-4", "k2") + ] }), + None, + ); + let channels = doc["channels"].as_array().unwrap(); + assert_eq!(channels.len(), 1, "只有合法且未重复的渠道保留"); + assert_eq!(channels[0]["id"], "ch-4"); + } + + #[test] + fn default_model_falls_back_to_first() { + let mut entry = sample_channel("ch-1", "k"); + entry["defaultModel"] = json!("not-in-list"); + let doc = normalize_channels_doc(&json!({ "channels": [entry] }), None); + assert_eq!(doc["channels"][0]["defaultModel"], "gpt-4o"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d75f13c..7f6863c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,8 @@ mod utils; use commands::{ agent, assistant, cli_conflict, config, device, diagnose, extensions, hermes, hermes_providers, - logs, media, memory, messaging, pairing, portable, service, site_api, skills, update, + logs, media, memory, messaging, model_channels, pairing, portable, service, site_api, skills, + update, }; pub fn run() { @@ -200,6 +201,10 @@ pub fn run() { assistant::assistant_save_image, assistant::assistant_load_image, assistant::assistant_delete_image, + // 统一模型渠道 + model_channels::read_model_channels, + model_channels::write_model_channels, + model_channels::reveal_model_channel_key, // 云端媒体生成 media::read_media_config, media::write_media_config, diff --git a/src/components/sidebar.js b/src/components/sidebar.js index c0a6390..21014bb 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -127,6 +127,7 @@ function NAV_ITEMS_ENGINE_SELECT() { return [ function COMMON_NAV_ITEMS() { return [{ section: t('sidebar.sectionCommon'), items: [ + { route: '/model-channels', label: t('sidebar.modelChannels'), icon: 'channels-hub' }, { route: '/media', label: t('sidebar.media'), icon: 'media' }, ], }] } @@ -145,7 +146,8 @@ const ICONS = { logs: '', models: '', agents: '', - media: '', gateway: '', + media: '', + 'channels-hub': '', gateway: '', memory: '', inbox: '', folder: '', diff --git a/src/lib/model-channels.js b/src/lib/model-channels.js new file mode 100644 index 0000000..4c3132e --- /dev/null +++ b/src/lib/model-channels.js @@ -0,0 +1,202 @@ +/** + * 统一模型渠道 — 共享逻辑 + * + * 渠道是唯一维护入口(Base URL + API Key + 模型列表),通过显式同步推送到 + * OpenClaw / Hermes / 晴辰助手。同步全部组合现有 API 完成: + * - OpenClaw:read/write_openclaw_config(后端自带备份,合并保留未知字段) + * - Hermes:hermes_env_set(写 .env)+ hermes_model_config_save(自带备份) + * - 助手:一次性拷贝到 localStorage(clawpanel-assistant) + * 本模块自身不直接写任何引擎配置文件。 + */ +import { api } from './tauri-api.js' + +export const ASSISTANT_STORAGE_KEY = 'clawpanel-assistant' + +/** 渠道 apiType → Hermes transport 与回退 provider(仅 API Key 型三族可同步) */ +export const HERMES_TRANSPORT_MAP = { + 'openai-completions': { transport: 'openai_chat', fallbackProvider: 'openai' }, + 'anthropic-messages': { transport: 'anthropic_messages', fallbackProvider: 'anthropic' }, + 'google-generative-ai': { transport: 'google_gemini', fallbackProvider: 'google' }, +} + +/** 晴辰助手支持的 apiType 族(见 assistant.js normalizeApiType) */ +export const ASSISTANT_SUPPORTED_API_TYPES = [ + 'openai-completions', 'anthropic-messages', 'google-generative-ai', 'ollama', +] + +export function hermesSyncSupported(channel) { + return Boolean(HERMES_TRANSPORT_MAP[channel?.apiType]) +} + +export function assistantSyncSupported(channel) { + return ASSISTANT_SUPPORTED_API_TYPES.includes(channel?.apiType) +} + +/** + * 渠道内容指纹(djb2):基于脱敏字段计算,用于「已同步 / 有未同步变更」徽标。 + * apiKeyMask 参与计算 —— Key 更换时掩码随之变化。 + */ +export function channelFingerprint(channel) { + const src = JSON.stringify([ + channel?.name || '', + channel?.baseUrl || '', + channel?.apiType || '', + channel?.apiKeyMask || '', + (channel?.models || []).map(m => m.id), + channel?.defaultModel || '', + ]) + let hash = 5381 + for (let i = 0; i < src.length; i += 1) { + hash = ((hash << 5) + hash + src.charCodeAt(i)) >>> 0 + } + return hash.toString(16) +} + +/** OpenClaw providers 键:优先预设 key,否则由名称生成 slug */ +export function channelProviderKey(channel) { + const preset = String(channel?.presetKey || '').trim() + if (preset) return preset + const slug = String(channel?.name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug || `channel-${channel?.id || ''}` +} + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + +/** + * 同步到 OpenClaw:只 upsert 渠道对应的 models.providers.{key}, + * 展开旧对象保留未知字段;渠道模型为准但保留目标已有模型的测试元数据。 + */ +export async function syncChannelToOpenclaw(channel, { setDefault = false } = {}) { + const apiKey = await api.revealModelChannelKey(channel.id) + const config = asObject(await api.readOpenclawConfig()) + config.models = asObject(config.models) + config.models.providers = asObject(config.models.providers) + const providerKey = channelProviderKey(channel) + const existing = asObject(config.models.providers[providerKey]) + const existingModels = Array.isArray(existing.models) ? existing.models : [] + + const models = (channel.models || []).map(model => { + const prev = existingModels.find(e => (typeof e === 'string' ? e : e?.id) === model.id) + const prevObj = prev && typeof prev === 'object' ? prev : {} + const merged = { ...prevObj, id: model.id } + if (model.name) merged.name = model.name + if (model.contextWindow) merged.contextWindow = model.contextWindow + return Object.keys(merged).length > 1 ? merged : model.id + }) + + config.models.providers[providerKey] = { + ...existing, + baseUrl: channel.baseUrl, + api: channel.apiType, + apiKey, + models, + } + + if (setDefault && channel.defaultModel) { + config.agents = asObject(config.agents) + config.agents.defaults = asObject(config.agents.defaults) + config.agents.defaults.model = { + ...asObject(config.agents.defaults.model), + primary: `${providerKey}/${channel.defaultModel}`, + } + } + + await api.writeOpenclawConfig(config) + return { providerKey, modelCount: models.length } +} + +/** 解析渠道对应的 Hermes provider(API Key 型 + transport 匹配),不支持返回 null */ +export async function resolveHermesTarget(channel) { + const mapping = HERMES_TRANSPORT_MAP[channel?.apiType] + if (!mapping) return null + const raw = await api.hermesListProviders().catch(() => null) + const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.providers) ? raw.providers : []) + const usable = p => p + && p.authType === 'api_key' + && p.transport === mapping.transport + && Array.isArray(p.apiKeyEnvVars) && p.apiKeyEnvVars.length > 0 + const byPreset = list.find(p => p.id === String(channel?.presetKey || '') && usable(p)) + const fallback = list.find(p => p.id === mapping.fallbackProvider && usable(p)) + return byPreset || fallback || null +} + +/** + * 同步到 Hermes:API Key 写入对应环境变量(Hermes .env), + * 自定义 Base URL 写 baseUrlEnvVar;可选设为默认模型(config.yaml,自带备份)。 + */ +export async function syncChannelToHermes(channel, { setDefault = false } = {}) { + const target = await resolveHermesTarget(channel) + if (!target) throw new Error('unsupported') + const apiKey = await api.revealModelChannelKey(channel.id) + if (!apiKey) throw new Error('no-key') + + await api.hermesEnvSet(target.apiKeyEnvVars[0], apiKey) + + const targetBase = String(target.baseUrl || '').replace(/\/+$/, '') + const baseUrlDiffers = Boolean(channel.baseUrl) && channel.baseUrl !== targetBase + if (baseUrlDiffers && target.baseUrlEnvVar) { + await api.hermesEnvSet(target.baseUrlEnvVar, channel.baseUrl) + } + + if (setDefault && channel.defaultModel) { + const modelDefault = channel.defaultModel.includes('/') + ? channel.defaultModel + : `${target.id}/${channel.defaultModel}` + await api.hermesModelConfigSave({ + modelDefault, + modelProvider: target.id, + modelBaseUrl: baseUrlDiffers && !target.baseUrlEnvVar ? channel.baseUrl : '', + }) + } + return { providerId: target.id, envKey: target.apiKeyEnvVars[0] } +} + +/** + * 同步到晴辰助手:一次性拷贝接入信息(渠道后续变更需再次同步)。 + * apiKey 由调用方 reveal 后传入,避免本模块内多次取明文。 + */ +export function syncChannelToAssistant(channel, apiKey, model = '') { + let config = {} + try { + config = JSON.parse(localStorage.getItem(ASSISTANT_STORAGE_KEY) || '{}') || {} + } catch { + config = {} + } + config.baseUrl = channel.baseUrl + config.apiKey = apiKey + config.model = model || channel.defaultModel || config.model || '' + config.apiType = channel.apiType + localStorage.setItem(ASSISTANT_STORAGE_KEY, JSON.stringify(config)) + return { model: config.model } +} + +/** 从 OpenClaw 现有 providers 生成渠道(跳过已按 presetKey 存在的),用于冷启动导入 */ +export async function importChannelsFromOpenclaw(existingChannels = []) { + const config = await api.readOpenclawConfig().catch(() => null) + const providers = asObject(asObject(config?.models).providers) + const taken = new Set(existingChannels.map(c => c.presetKey).filter(Boolean)) + const imported = [] + for (const [key, provider] of Object.entries(providers)) { + if (!provider || typeof provider !== 'object' || taken.has(key)) continue + const models = (Array.isArray(provider.models) ? provider.models : []) + .map(m => (typeof m === 'string' ? { id: m } : { id: String(m?.id || '').trim(), name: m?.name })) + .filter(m => m.id) + imported.push({ + id: `ch-${key}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e4)}`, + name: key, + presetKey: key, + baseUrl: String(provider.baseUrl || '').trim().replace(/\/+$/, ''), + apiType: String(provider.api || 'openai-completions').trim(), + apiKey: String(provider.apiKey || ''), + models, + defaultModel: '', + enabled: true, + }) + } + return imported +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 301be4d..46940e1 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -311,6 +311,14 @@ export const api = { listRemoteModels: (baseUrl, apiKey, apiType = null) => invoke('list_remote_models', { baseUrl, apiKey, apiType }), scanModelClientConfigs: () => invoke('scan_model_client_configs'), + // 统一模型渠道 + readModelChannels: () => cachedInvoke('read_model_channels', {}, 5000), + writeModelChannels: (config) => { + invalidate('read_model_channels') + return invoke('write_model_channels', { config }) + }, + revealModelChannelKey: (channelId) => invoke('reveal_model_channel_key', { channelId }), + // Agent 管理 listAgents: () => cachedInvoke('list_agents'), getAgentDetail: (id) => cachedInvoke('get_agent_detail', { id }, 5000), diff --git a/src/locales/index.js b/src/locales/index.js index 852bd2a..63606c2 100644 --- a/src/locales/index.js +++ b/src/locales/index.js @@ -43,6 +43,7 @@ import notifications from './modules/notifications.js' import kernel from './modules/kernel.js' import siteMessages from './modules/siteMessages.js' import media from './modules/media.js' +import modelChannels from './modules/model-channels.js' const MODULES = { common, sidebar, instance, dashboard, services, settings, @@ -50,6 +51,7 @@ const MODULES = { memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about, ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions, engine, ciaoBug, cliConflict, glossary, hermesLazyDeps, notifications, kernel, siteMessages, media, + modelChannels, } /** 判断是否是 _() 调用产生的翻译对象(有 'zh-CN' 字符串字段) */ diff --git a/src/locales/modules/model-channels.js b/src/locales/modules/model-channels.js new file mode 100644 index 0000000..79c6436 --- /dev/null +++ b/src/locales/modules/model-channels.js @@ -0,0 +1,63 @@ +import { _ } from '../helper.js' + +// 统一模型渠道页面(t('modelChannels.*')) +export default { + title: _('模型渠道', 'Model Channels', '模型渠道'), + desc: _('一处维护模型接入配置(地址、密钥、模型列表),一键同步到 OpenClaw、Hermes 和晴辰助手。', 'Maintain model access (base URL, API key, models) in one place and sync it to OpenClaw, Hermes, and the assistant.', '一處維護模型接入設定(位址、金鑰、模型清單),一鍵同步到 OpenClaw、Hermes 和晴辰助手。'), + localOnlyHint: _('渠道只保存在本机,密钥读取时仅显示掩码。', 'Channels are stored locally; keys are always masked when read.', '渠道只儲存在本機,金鑰讀取時僅顯示遮罩。'), + addChannel: _('新建渠道', 'New Channel', '新建渠道'), + importExisting: _('从现有配置导入', 'Import Existing', '從現有設定匯入'), + importNone: _('没有可导入的新配置', 'Nothing new to import', '沒有可匯入的新設定'), + importDone: _('已导入 {count} 个渠道', 'Imported {count} channels', '已匯入 {count} 個渠道'), + empty: _('还没有模型渠道。新建一个,或从 OpenClaw 现有模型配置一键导入。', 'No channels yet. Create one, or import from your existing OpenClaw model config.', '還沒有模型渠道。新建一個,或從 OpenClaw 現有模型設定一鍵匯入。'), + howTitle: _('使用方式', 'How it works', '使用方式'), + how1: _('新建渠道:选择服务商预设或自定义,填写接口地址与 API Key,拉取或手填可用模型', 'Create a channel: pick a provider preset or custom, fill in the base URL and API key, then fetch or type the models', '新建渠道:選擇服務商預設或自訂,填寫介面位址與 API Key,拉取或手填可用模型'), + how2: _('点「同步到…」把渠道推送给 OpenClaw / Hermes / 晴辰助手,无需再各处重复配置', 'Click "Sync to…" to push the channel to OpenClaw / Hermes / the assistant — no more duplicate setup', '點「同步到…」把渠道推送給 OpenClaw / Hermes / 晴辰助手,無需再各處重複設定'), + how3: _('渠道修改后重新同步即可;写入 OpenClaw / Hermes 前会自动备份目标配置', 'Re-sync after editing a channel; target configs are backed up automatically before writes', '渠道修改後重新同步即可;寫入 OpenClaw / Hermes 前會自動備份目標設定'), + name: _('渠道名称', 'Channel Name', '渠道名稱'), + namePlaceholder: _('例如:我的 OpenAI', 'e.g. My OpenAI', '例如:我的 OpenAI'), + preset: _('服务商预设', 'Provider Preset', '服務商預設'), + presetCustom: _('自定义', 'Custom', '自訂'), + presetHint: _('选择预设会自动填入接口地址与 API 类型', 'Picking a preset fills in the base URL and API type', '選擇預設會自動填入介面位址與 API 類型'), + baseUrl: _('接口地址 (Base URL)', 'Base URL', '介面位址 (Base URL)'), + apiType: _('API 类型', 'API Type', 'API 類型'), + apiKey: _('API Key', 'API Key', 'API Key'), + apiKeyHint: _('留空表示保持已保存的 Key 不变', 'Leave empty to keep the saved key', '留空表示保持已儲存的 Key 不變'), + keySaved: _('已保存:{mask}', 'Saved: {mask}', '已儲存:{mask}'), + keyMissing: _('未配置 Key', 'No API key', '未設定 Key'), + models: _('模型列表', 'Models', '模型清單'), + modelsHint: _('每行一个模型 ID,也可以点「获取模型列表」自动拉取', 'One model ID per line, or click "Fetch Models" to pull the list', '每行一個模型 ID,也可以點「獲取模型清單」自動拉取'), + fetchModels: _('获取模型列表', 'Fetch Models', '獲取模型清單'), + fetching: _('正在获取…', 'Fetching…', '正在獲取…'), + fetchOk: _('获取到 {count} 个模型,已合并进列表', 'Fetched {count} models and merged them into the list', '獲取到 {count} 個模型,已合併進清單'), + fetchEmpty: _('服务商未返回可识别的模型,请手动填写', 'The provider returned no recognizable models; please type them manually', '服務商未返回可識別的模型,請手動填寫'), + fetchNeedInput: _('请先填写接口地址和 API Key', 'Fill in the base URL and API key first', '請先填寫介面位址和 API Key'), + defaultModel: _('默认模型', 'Default Model', '預設模型'), + defaultModelNone: _('(取列表第一个)', '(first in list)', '(取清單第一個)'), + save: _('保存渠道', 'Save Channel', '儲存渠道'), + editChannel: _('编辑渠道', 'Edit Channel', '編輯渠道'), + deleteConfirm: _('删除渠道「{name}」?已同步到 OpenClaw / Hermes / 助手的配置不会被删除。', 'Delete channel "{name}"? Configs already synced to OpenClaw / Hermes / the assistant will not be removed.', '刪除渠道「{name}」?已同步到 OpenClaw / Hermes / 助手的設定不會被刪除。'), + deleted: _('渠道已删除', 'Channel deleted', '渠道已刪除'), + saved: _('渠道已保存', 'Channel saved', '渠道已儲存'), + nameRequired: _('请填写渠道名称', 'Channel name is required', '請填寫渠道名稱'), + baseUrlRequired: _('请填写以 http:// 或 https:// 开头的接口地址', 'Base URL must start with http:// or https://', '請填寫以 http:// 或 https:// 開頭的介面位址'), + modelCount: _('{count} 个模型', '{count} models', '{count} 個模型'), + syncOpenclaw: _('同步到 OpenClaw', 'Sync to OpenClaw', '同步到 OpenClaw'), + syncHermes: _('同步到 Hermes', 'Sync to Hermes', '同步到 Hermes'), + syncAssistant: _('同步到助手', 'Sync to Assistant', '同步到助手'), + stateSynced: _('已同步', 'Synced', '已同步'), + stateDrift: _('有未同步变更', 'Out of sync', '有未同步變更'), + stateNever: _('未同步', 'Not synced', '未同步'), + stateUnsupported: _('不支持', 'Unsupported', '不支援'), + targetOpenclaw: _('OpenClaw', 'OpenClaw', 'OpenClaw'), + targetHermes: _('Hermes', 'Hermes', 'Hermes'), + targetAssistant: _('助手', 'Assistant', '助手'), + syncOpenclawConfirm: _('将写入 OpenClaw 模型配置:provider「{key}」,{count} 个模型。写入前自动备份,其他 provider 与未知字段不受影响。', 'This writes provider "{key}" with {count} models into the OpenClaw model config. A backup is created first; other providers and unknown fields are untouched.', '將寫入 OpenClaw 模型設定:provider「{key}」,{count} 個模型。寫入前自動備份,其他 provider 與未知欄位不受影響。'), + syncHermesConfirm: _('将把 API Key 写入 Hermes 环境变量 {env}(provider:{provider}),写入前自动备份配置。', 'This writes the API key into the Hermes env var {env} (provider: {provider}). Config is backed up before writes.', '將把 API Key 寫入 Hermes 環境變數 {env}(provider:{provider}),寫入前自動備份設定。'), + syncHermesUnsupported: _('该 API 类型暂不支持同步到 Hermes(支持 OpenAI / Anthropic / Gemini 兼容接口)', 'This API type cannot be synced to Hermes yet (OpenAI / Anthropic / Gemini compatible only)', '該 API 類型暫不支援同步到 Hermes(支援 OpenAI / Anthropic / Gemini 相容介面)'), + syncAssistantConfirm: _('将把该渠道的地址与 API Key 拷贝给晴辰助手(模型:{model})。渠道更新后需再次同步。', 'This copies the channel base URL and API key to the assistant (model: {model}). Re-sync after future edits.', '將把該渠道的位址與 API Key 拷貝給晴辰助手(模型:{model})。渠道更新後需再次同步。'), + syncAssistantUnsupported: _('晴辰助手暂不支持该 API 类型', 'The assistant does not support this API type yet', '晴辰助手暫不支援該 API 類型'), + syncSetDefaultAsk: _('是否同时将「{model}」设为默认模型?', 'Also set "{model}" as the default model?', '是否同時將「{model}」設為預設模型?'), + syncDone: _('已同步到 {target}', 'Synced to {target}', '已同步到 {target}'), + noKeyForSync: _('渠道未保存 API Key,无法同步', 'The channel has no saved API key; sync aborted', '渠道未儲存 API Key,無法同步'), +} diff --git a/src/locales/modules/sidebar.js b/src/locales/modules/sidebar.js index 693bf1e..d891a10 100644 --- a/src/locales/modules/sidebar.js +++ b/src/locales/modules/sidebar.js @@ -20,6 +20,7 @@ export default { models: _('模型配置', 'Models', '模型設定', 'モデル設定', '모델 설정', 'Mô hình', 'Modelos', 'Modelos', 'Модели', 'Modèles', 'Modelle'), agents: _('Agent 管理', 'Agents', '', 'Agent 管理', 'Agent 관리', 'Agent', 'Agentes', 'Agentes', 'Агенты', '', 'Agenten'), media: _('创作中心', 'Media Studio', '創作中心', 'メディアスタジオ', '미디어 스튜디오', 'Studio media', 'Estudio multimedia', 'Estúdio de mídia', 'Медиа-студия', 'Studio média', 'Medien-Studio'), + modelChannels: _('模型渠道', 'Model Channels', '模型渠道', 'モデルチャネル', '모델 채널', 'Kênh mô hình', 'Canales de modelos', 'Canais de modelos', 'Каналы моделей', 'Canaux de modèles', 'Modellkanäle'), gateway: _('Gateway', 'Gateway'), channels: _('消息渠道', 'Channels', '訊息頻道', 'チャンネル', '채널', 'Kênh', 'Canales', 'Canais', 'Каналы', 'Canaux', 'Kanäle'), communication: _('通信与自动化', 'Communication', '通信與自動化', '通信と自動化', '통신 및 자동화', 'Truyền thông', 'Comunicación', 'Comunicação', 'Коммуникации', '', 'Kommunikation'), diff --git a/src/main.js b/src/main.js index e0954d0..d8536ad 100644 --- a/src/main.js +++ b/src/main.js @@ -631,6 +631,7 @@ async function boot() { registerEngine(xintianEngine) registerRoute('/engine-select', () => import('./pages/engine-select.js')) registerRoute('/media', () => import('./pages/media.js')) + registerRoute('/model-channels', () => import('./pages/model-channels.js')) // 初始化引擎管理器:读取 clawpanel.json 的 engineMode,注册对应路由 await initEngineManager() diff --git a/src/pages/model-channels.js b/src/pages/model-channels.js new file mode 100644 index 0000000..7bdf452 --- /dev/null +++ b/src/pages/model-channels.js @@ -0,0 +1,491 @@ +import { api } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { showConfirm } from '../components/modal.js' +import { icon } from '../lib/icons.js' +import { t } from '../lib/i18n.js' +import { PROVIDER_PRESETS, API_TYPES } from '../lib/model-presets.js' +import { + channelFingerprint, + channelProviderKey, + hermesSyncSupported, + assistantSyncSupported, + syncChannelToOpenclaw, + syncChannelToHermes, + syncChannelToAssistant, + importChannelsFromOpenclaw, +} from '../lib/model-channels.js' + +function esc(value) { + if (value == null) return '' + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function attr(value) { + return esc(value).replace(/'/g, ''') +} + +function newChannelId() { + return `ch-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}` +} + +export async function render() { + const page = document.createElement('div') + page.className = 'page channels-hub-page' + const state = { + doc: { version: 1, channels: [], syncState: {} }, + loaded: false, + editing: null, // 编辑中的渠道草稿(null = 未打开编辑器) + fetchBusy: false, + syncBusy: '', + } + + page.innerHTML = ` + + +
+
+
+ ` + + bindEvents(page, state) + loadInitial(page, state) + return page +} + +async function loadInitial(page, state) { + try { + state.doc = await api.readModelChannels() + state.loaded = true + renderBody(page, state) + } catch (error) { + console.error('[model-channels] load failed', error) + page.querySelector('#mch-body').innerHTML = `
${esc(error?.message || String(error))}
` + } +} + +function syncStateOf(state, target, channel) { + const record = state.doc?.syncState?.[target]?.[channel.id] + if (!record) return 'never' + return record.hash === channelFingerprint(channel) ? 'synced' : 'drift' +} + +function renderBody(page, state) { + const body = page.querySelector('#mch-body') + const channels = state.doc?.channels || [] + body.innerHTML = ` + ${channels.length ? '' : renderHow()} +
+ + +
+ ${channels.length + ? `
${channels.map(ch => renderChannelCard(state, ch)).join('')}
` + : `
${icon('plug', 28)}
${t('modelChannels.empty')}
`} + ${state.editing ? renderEditor(state) : ''} + ` +} + +function renderHow() { + return ` +
+
${icon('lightbulb', 14)} ${t('modelChannels.howTitle')}
+
    +
  1. 1${t('modelChannels.how1')}
  2. +
  3. 2${t('modelChannels.how2')}
  4. +
  5. 3${t('modelChannels.how3')}
  6. +
+
+ ` +} + +function renderSyncLine(state, channel, target, label, supported, unsupportedHint, syncLabel) { + if (!supported) { + return ` +
+
${label}
+ ${t('modelChannels.stateUnsupported')} +
+ ` + } + const stateKey = syncStateOf(state, target, channel) + const stateLabel = stateKey === 'synced' ? t('modelChannels.stateSynced') + : stateKey === 'drift' ? t('modelChannels.stateDrift') : t('modelChannels.stateNever') + return ` +
+
${label} ${stateLabel}
+ +
+ ` +} + +function renderChannelCard(state, channel) { + const presetLabel = PROVIDER_PRESETS.find(p => p.key === channel.presetKey)?.label || t('modelChannels.presetCustom') + const keyInfo = channel.apiKeySaved + ? t('modelChannels.keySaved', { mask: channel.apiKeyMask || '***' }) + : t('modelChannels.keyMissing') + return ` +
+
+
${icon('plug', 16)} ${esc(channel.name)}
+ ${esc(presetLabel)} +
+
${esc(channel.baseUrl || '-')}
+
+ ${esc(channel.apiType)} + ${icon('box', 11)} ${t('modelChannels.modelCount', { count: (channel.models || []).length })} + ${icon('key', 11)} ${esc(keyInfo)} + ${channel.defaultModel ? `${icon('check', 11)} ${esc(channel.defaultModel)}` : ''} +
+
+ ${renderSyncLine(state, channel, 'openclaw', t('modelChannels.targetOpenclaw'), true, '', t('modelChannels.syncOpenclaw'))} + ${renderSyncLine(state, channel, 'hermes', t('modelChannels.targetHermes'), hermesSyncSupported(channel), t('modelChannels.syncHermesUnsupported'), t('modelChannels.syncHermes'))} + ${renderSyncLine(state, channel, 'assistant', t('modelChannels.targetAssistant'), assistantSyncSupported(channel), t('modelChannels.syncAssistantUnsupported'), t('modelChannels.syncAssistant'))} +
+
+ + +
+
+ ` +} + +function renderEditor(state) { + const draft = state.editing + const presetOptions = [ + ``, + ...PROVIDER_PRESETS.map(p => ``), + ].join('') + const modelLines = (draft.models || []).map(m => m.id).join('\n') + const modelIds = (draft.models || []).map(m => m.id) + return ` + + ` +} + +// 从编辑器 DOM 收集草稿(保存 / 拉取模型共用) +function collectDraft(page, state) { + const draft = state.editing + if (!draft) return null + draft.name = page.querySelector('#mch-name')?.value?.trim() || '' + draft.presetKey = page.querySelector('#mch-preset')?.value || '' + draft.baseUrl = (page.querySelector('#mch-base-url')?.value || '').trim().replace(/\/+$/, '') + draft.apiType = page.querySelector('#mch-api-type')?.value || 'openai-completions' + draft.typedKey = page.querySelector('#mch-api-key')?.value?.trim() || '' + const lines = (page.querySelector('#mch-models')?.value || '') + .split('\n').map(line => line.trim()).filter(Boolean) + const seen = new Set() + draft.models = lines.filter(id => (seen.has(id) ? false : seen.add(id))).map(id => ({ id })) + draft.defaultModel = page.querySelector('#mch-default-model')?.value || '' + return draft +} + +function bindEvents(page, state) { + page.addEventListener('change', (event) => { + // 选择预设 → 自动填 Base URL + API 类型(自定义则不动现有值) + if (event.target.id === 'mch-preset' && state.editing) { + const preset = PROVIDER_PRESETS.find(p => p.key === event.target.value) + if (preset) { + const baseUrlInput = page.querySelector('#mch-base-url') + const apiTypeSelect = page.querySelector('#mch-api-type') + if (baseUrlInput && preset.baseUrl) baseUrlInput.value = preset.baseUrl + if (apiTypeSelect && preset.api) apiTypeSelect.value = preset.api + const nameInput = page.querySelector('#mch-name') + if (nameInput && !nameInput.value.trim()) nameInput.value = preset.label + } + } + }) + + // 模型列表变化时同步默认模型下拉选项 + page.addEventListener('input', (event) => { + if (event.target.id === 'mch-models' && state.editing) { + const select = page.querySelector('#mch-default-model') + if (!select) return + const current = select.value + const ids = event.target.value.split('\n').map(v => v.trim()).filter(Boolean) + select.innerHTML = `` + + ids.map(id => ``).join('') + } + }) + + page.addEventListener('click', async (event) => { + const actionEl = event.target.closest('[data-action]') + if (!actionEl) return + const action = actionEl.dataset.action + try { + if (action === 'add') { + state.editing = { + id: newChannelId(), isNew: true, name: '', presetKey: '', baseUrl: '', + apiType: 'openai-completions', apiKeySaved: false, apiKeyMask: '', + models: [], defaultModel: '', typedKey: '', + } + renderBody(page, state) + } else if (action === 'edit') { + const channel = (state.doc.channels || []).find(c => c.id === actionEl.dataset.channelId) + if (channel) { + state.editing = { ...channel, models: (channel.models || []).map(m => ({ ...m })), isNew: false, typedKey: '' } + renderBody(page, state) + } + } else if (action === 'editor-cancel') { + state.editing = null + renderBody(page, state) + } else if (action === 'editor-save') { + await saveEditor(page, state) + } else if (action === 'fetch-models') { + await fetchModelsIntoEditor(page, state) + } else if (action === 'delete') { + await deleteChannel(page, state, actionEl.dataset.channelId) + } else if (action === 'import') { + await importExisting(page, state) + } else if (action === 'sync') { + await syncChannel(page, state, actionEl.dataset.channelId, actionEl.dataset.target) + } + } catch (error) { + toast(error?.message || String(error), 'error') + } + }) +} + +async function persistDoc(state) { + state.doc = await api.writeModelChannels(state.doc) +} + +async function saveEditor(page, state) { + const draft = collectDraft(page, state) + if (!draft) return + if (!draft.name) { toast(t('modelChannels.nameRequired'), 'warning'); return } + if (!/^https?:\/\//.test(draft.baseUrl)) { toast(t('modelChannels.baseUrlRequired'), 'warning'); return } + const channel = { + id: draft.id, + name: draft.name, + presetKey: draft.presetKey, + baseUrl: draft.baseUrl, + apiType: draft.apiType, + // 留空 = 保持已保存 Key(后端 __KEEP__ 语义) + apiKey: draft.typedKey || '', + models: draft.models, + defaultModel: draft.defaultModel, + enabled: true, + } + const channels = [...(state.doc.channels || [])] + const index = channels.findIndex(c => c.id === channel.id) + if (index >= 0) channels[index] = channel + else channels.push(channel) + state.doc = { ...state.doc, channels } + await persistDoc(state) + state.editing = null + toast(t('modelChannels.saved'), 'success') + renderBody(page, state) +} + +async function fetchModelsIntoEditor(page, state) { + const draft = collectDraft(page, state) + if (!draft) return + // 明文 Key:优先编辑框输入,其次已保存渠道 reveal + let apiKey = draft.typedKey + if (!apiKey && draft.apiKeySaved) { + apiKey = await api.revealModelChannelKey(draft.id).catch(() => '') + } + if (!/^https?:\/\//.test(draft.baseUrl) || !apiKey) { + toast(t('modelChannels.fetchNeedInput'), 'warning') + return + } + state.fetchBusy = true + renderBody(page, state) + try { + const ids = await api.listRemoteModels(draft.baseUrl, apiKey, draft.apiType) + const merged = [...new Set([...(draft.models || []).map(m => m.id), ...(Array.isArray(ids) ? ids : [])])] + state.editing.models = merged.map(id => ({ id })) + toast(merged.length ? t('modelChannels.fetchOk', { count: (Array.isArray(ids) ? ids : []).length }) : t('modelChannels.fetchEmpty'), merged.length ? 'success' : 'info') + } finally { + state.fetchBusy = false + renderBody(page, state) + } +} + +async function deleteChannel(page, state, channelId) { + const channel = (state.doc.channels || []).find(c => c.id === channelId) + if (!channel) return + const ok = await showConfirm(t('modelChannels.deleteConfirm', { name: channel.name })) + if (!ok) return + state.doc = { ...state.doc, channels: (state.doc.channels || []).filter(c => c.id !== channelId) } + for (const target of ['openclaw', 'hermes', 'assistant']) { + if (state.doc.syncState?.[target]) delete state.doc.syncState[target][channelId] + } + await persistDoc(state) + toast(t('modelChannels.deleted'), 'success') + renderBody(page, state) +} + +async function importExisting(page, state) { + const imported = await importChannelsFromOpenclaw(state.doc.channels || []) + if (!imported.length) { + toast(t('modelChannels.importNone'), 'info') + return + } + state.doc = { ...state.doc, channels: [...(state.doc.channels || []), ...imported] } + await persistDoc(state) + toast(t('modelChannels.importDone', { count: imported.length }), 'success') + renderBody(page, state) +} + +function recordSync(state, target, channel, extra = {}) { + state.doc.syncState = state.doc.syncState && typeof state.doc.syncState === 'object' ? state.doc.syncState : {} + state.doc.syncState[target] = state.doc.syncState[target] || {} + state.doc.syncState[target][channel.id] = { + hash: channelFingerprint(channel), + at: new Date().toISOString(), + ...extra, + } +} + +async function syncChannel(page, state, channelId, target) { + const channel = (state.doc.channels || []).find(c => c.id === channelId) + if (!channel || state.syncBusy) return + if (!channel.apiKeySaved) { toast(t('modelChannels.noKeyForSync'), 'warning'); return } + + state.syncBusy = target + renderBody(page, state) + try { + if (target === 'openclaw') { + const providerKey = channelProviderKey(channel) + const ok = await showConfirm(t('modelChannels.syncOpenclawConfirm', { key: providerKey, count: (channel.models || []).length }), { variant: 'primary' }) + if (!ok) return + let setDefault = false + if (channel.defaultModel) { + setDefault = await showConfirm(t('modelChannels.syncSetDefaultAsk', { model: channel.defaultModel }), { variant: 'primary' }) + } + const result = await syncChannelToOpenclaw(channel, { setDefault }) + recordSync(state, target, channel, { providerKey: result.providerKey }) + await persistDoc(state) + toast(t('modelChannels.syncDone', { target: t('modelChannels.targetOpenclaw') }), 'success') + } else if (target === 'hermes') { + const { resolveHermesTarget } = await import('../lib/model-channels.js') + const hermesTarget = await resolveHermesTarget(channel) + if (!hermesTarget) { toast(t('modelChannels.syncHermesUnsupported'), 'warning'); return } + const ok = await showConfirm(t('modelChannels.syncHermesConfirm', { env: hermesTarget.apiKeyEnvVars[0], provider: hermesTarget.id }), { variant: 'primary' }) + if (!ok) return + let setDefault = false + if (channel.defaultModel) { + setDefault = await showConfirm(t('modelChannels.syncSetDefaultAsk', { model: channel.defaultModel }), { variant: 'primary' }) + } + const result = await syncChannelToHermes(channel, { setDefault }) + recordSync(state, target, channel, { providerId: result.providerId }) + await persistDoc(state) + toast(t('modelChannels.syncDone', { target: t('modelChannels.targetHermes') }), 'success') + } else if (target === 'assistant') { + const model = channel.defaultModel || channel.models?.[0]?.id || '' + const ok = await showConfirm(t('modelChannels.syncAssistantConfirm', { model: model || '-' }), { variant: 'primary' }) + if (!ok) return + const apiKey = await api.revealModelChannelKey(channel.id) + if (!apiKey) { toast(t('modelChannels.noKeyForSync'), 'warning'); return } + syncChannelToAssistant(channel, apiKey, model) + recordSync(state, target, channel, { model }) + await persistDoc(state) + toast(t('modelChannels.syncDone', { target: t('modelChannels.targetAssistant') }), 'success') + } + } catch (error) { + if (error?.message === 'unsupported') toast(t('modelChannels.syncHermesUnsupported'), 'warning') + else if (error?.message === 'no-key') toast(t('modelChannels.noKeyForSync'), 'warning') + else throw error + } finally { + state.syncBusy = '' + renderBody(page, state) + } +} diff --git a/tests/model-channels.test.js b/tests/model-channels.test.js new file mode 100644 index 0000000..20afa95 --- /dev/null +++ b/tests/model-channels.test.js @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const read = rel => readFileSync(new URL(rel, import.meta.url), 'utf8') + +const lib = read('../src/lib/model-channels.js') +const page = read('../src/pages/model-channels.js') +const mainJs = read('../src/main.js') +const sidebar = read('../src/components/sidebar.js') +const tauriApi = read('../src/lib/tauri-api.js') +const devApi = read('../scripts/dev-api.js') +const rustLib = read('../src-tauri/src/lib.rs') +const rustModule = read('../src-tauri/src/commands/model_channels.rs') +const localesIndex = read('../src/locales/index.js') + +test('模型渠道命令注册链完整(Rust + tauri-api + dev-api + ALWAYS_LOCAL)', () => { + for (const cmd of ['read_model_channels', 'write_model_channels', 'reveal_model_channel_key']) { + assert.match(rustLib, new RegExp(`model_channels::${cmd}`), `lib.rs 缺少 ${cmd} 注册`) + assert.match(devApi, new RegExp(`${cmd}\\(`), `dev-api.js 缺少 ${cmd} handler`) + assert.match(devApi, new RegExp(`'${cmd}'`), `${cmd} 必须加入 ALWAYS_LOCAL(本机属性不可代理远程)`) + } + assert.match(tauriApi, /readModelChannels:/, 'tauri-api 缺少 readModelChannels 封装') + assert.match(tauriApi, /writeModelChannels:/, 'tauri-api 缺少 writeModelChannels 封装') + assert.match(tauriApi, /revealModelChannelKey:/, 'tauri-api 缺少 revealModelChannelKey 封装') +}) + +test('渠道读取只返回掩码,写入支持保留旧 Key 哨兵', () => { + assert.match(rustModule, /apiKeySaved/, '读取必须返回 apiKeySaved') + assert.match(rustModule, /apiKeyMask/, '读取必须返回 apiKeyMask') + assert.match(rustModule, /__KEEP__/, '写入必须支持 __KEEP__ 哨兵') + assert.match(devApi, /isChannelKeepSentinel/, 'dev-api 必须实现相同的哨兵语义') +}) + +test('Hermes 同步仅覆盖 API Key 型三类 transport', () => { + assert.match(lib, /'openai-completions':\s*\{\s*transport:\s*'openai_chat'/, 'openai transport 映射缺失') + assert.match(lib, /'anthropic-messages':\s*\{\s*transport:\s*'anthropic_messages'/, 'anthropic transport 映射缺失') + assert.match(lib, /'google-generative-ai':\s*\{\s*transport:\s*'google_gemini'/, 'gemini transport 映射缺失') + assert.match(lib, /authType === 'api_key'/, 'OAuth/SDK 型 provider 必须被排除在渠道同步之外') +}) + +test('OpenClaw 同步只 upsert 单个 provider 并保留未知字段', () => { + assert.match(lib, /\.\.\.existing,/, '写入 provider 时必须展开旧对象保留未知字段') + assert.match(lib, /config\.models\.providers\[providerKey\]/, '必须按 provider 键 upsert 而非整体重写') +}) + +test('同步与删除必须经过确认弹窗', () => { + assert.match(page, /showConfirm\(t\('modelChannels\.syncOpenclawConfirm'/, '同步 OpenClaw 前必须确认') + assert.match(page, /showConfirm\(t\('modelChannels\.syncHermesConfirm'/, '同步 Hermes 前必须确认') + assert.match(page, /showConfirm\(t\('modelChannels\.syncAssistantConfirm'/, '同步助手前必须确认') + assert.match(page, /showConfirm\(t\('modelChannels\.deleteConfirm'/, '删除渠道前必须确认') +}) + +test('页面注册链完整(路由 + 侧栏 + 语言包)', () => { + assert.match(mainJs, /registerRoute\('\/model-channels'/, 'main.js 缺少路由注册') + assert.match(sidebar, /route: '\/model-channels'/, '侧栏缺少入口') + assert.match(sidebar, /'channels-hub':/, '侧栏缺少图标') + assert.match(localesIndex, /modelChannels/, '语言包聚合缺少 modelChannels 模块') +})