mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-13 00:11:52 +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:
10
CHANGELOG.md
10
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
319
src-tauri/src/commands/model_channels.rs
Normal file
319
src-tauri/src/commands/model_channels.rs
Normal file
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> = 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::<Value>(&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<Value, String> {
|
||||
Ok(sanitize_doc_for_read(&read_channels_private()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_model_channels(config: Value) -> Result<Value, String> {
|
||||
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<String, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
|
||||
models: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>',
|
||||
agents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>',
|
||||
media: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="M21 15l-5-5L5 21"/><path d="M15 5l4 4"/><path d="M19 5l-4 4"/></svg>', gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
|
||||
media: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="M21 15l-5-5L5 21"/><path d="M15 5l4 4"/><path d="M19 5l-4 4"/></svg>',
|
||||
'channels-hub': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22v-5"/><path d="M9 8V1h6v7"/><path d="M7 8h10a0 0 0 010 0 5 5 0 01-10 0 0 0 0 010 0z"/><path d="M12 8v5"/></svg>', gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
|
||||
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
|
||||
inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>',
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>',
|
||||
|
||||
202
src/lib/model-channels.js
Normal file
202
src/lib/model-channels.js
Normal file
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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' 字符串字段) */
|
||||
|
||||
63
src/locales/modules/model-channels.js
Normal file
63
src/locales/modules/model-channels.js
Normal file
@@ -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,無法同步'),
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
491
src/pages/model-channels.js
Normal file
491
src/pages/model-channels.js
Normal file
@@ -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, '>')
|
||||
.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 = `
|
||||
<style>
|
||||
.channels-hub-page .mch-header-row{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
||||
.channels-hub-page .mch-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid var(--border-primary);border-radius:8px;background:var(--bg-secondary);color:var(--text-secondary);font-size:12px}
|
||||
.channels-hub-page .mch-toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:16px 0}
|
||||
.channels-hub-page .mch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
||||
.channels-hub-page .mch-card{border:1px solid var(--border-primary);border-radius:10px;background:var(--bg-primary);padding:16px;display:grid;gap:10px;min-width:0}
|
||||
.channels-hub-page .mch-card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
|
||||
.channels-hub-page .mch-card-title{font-weight:700;color:var(--text-primary);display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.channels-hub-page .mch-card-title span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.channels-hub-page .mch-chip{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--border-primary);border-radius:999px;padding:2px 8px;font-size:11px;color:var(--text-tertiary);white-space:nowrap}
|
||||
.channels-hub-page .mch-url{font-family:var(--font-mono);font-size:12px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.channels-hub-page .mch-meta{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--text-tertiary)}
|
||||
.channels-hub-page .mch-sync-row{display:grid;gap:6px;border-top:1px solid var(--border-primary);padding-top:10px}
|
||||
.channels-hub-page .mch-sync-line{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.channels-hub-page .mch-sync-target{font-size:12px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;min-width:0}
|
||||
.channels-hub-page .mch-state{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--border-primary);color:var(--text-tertiary);white-space:nowrap}
|
||||
.channels-hub-page .mch-state.synced{color:var(--success);border-color:color-mix(in srgb,var(--success) 35%,var(--border-primary))}
|
||||
.channels-hub-page .mch-state.drift{color:var(--warning);border-color:color-mix(in srgb,var(--warning) 35%,var(--border-primary))}
|
||||
.channels-hub-page .mch-actions{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.channels-hub-page .mch-empty{border:1px dashed var(--border-primary);border-radius:10px;background:var(--bg-secondary);padding:40px 20px;display:grid;justify-items:center;gap:14px;color:var(--text-tertiary);text-align:center;line-height:1.7}
|
||||
.channels-hub-page .mch-how{border:1px solid var(--border-primary);border-radius:10px;background:var(--bg-secondary);padding:14px 16px;margin-bottom:16px}
|
||||
.channels-hub-page .mch-how ol{margin:8px 0 0;padding:0;list-style:none;display:grid;gap:6px}
|
||||
.channels-hub-page .mch-how li{display:flex;gap:8px;align-items:flex-start;color:var(--text-secondary);font-size:13px}
|
||||
.channels-hub-page .mch-how b{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;background:var(--primary);color:#fff;font-size:11px;flex:0 0 auto;margin-top:1px}
|
||||
.channels-hub-page .mch-editor{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.channels-hub-page .mch-editor .wide{grid-column:1/-1}
|
||||
.channels-hub-page .mch-editor textarea.form-input{min-height:120px;resize:vertical;font-family:var(--font-mono);font-size:12px;line-height:1.6}
|
||||
@media (max-width:700px){
|
||||
.channels-hub-page .mch-grid{grid-template-columns:1fr}
|
||||
.channels-hub-page .mch-editor{grid-template-columns:1fr}
|
||||
}
|
||||
</style>
|
||||
<div class="page-header">
|
||||
<div class="mch-header-row">
|
||||
<div>
|
||||
<h1 class="page-title">${t('modelChannels.title')}</h1>
|
||||
<p class="page-desc">${t('modelChannels.desc')}</p>
|
||||
</div>
|
||||
<div class="mch-badge">${icon('lock', 14)} ${t('modelChannels.localOnlyHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mch-body">
|
||||
<div class="stat-card loading-placeholder" style="height:120px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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 = `<div class="mch-empty">${esc(error?.message || String(error))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
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()}
|
||||
<div class="mch-toolbar">
|
||||
<button class="btn btn-primary" type="button" data-action="add">${icon('plus-circle', 14)} ${t('modelChannels.addChannel')}</button>
|
||||
<button class="btn btn-secondary" type="button" data-action="import">${icon('download', 14)} ${t('modelChannels.importExisting')}</button>
|
||||
</div>
|
||||
${channels.length
|
||||
? `<div class="mch-grid">${channels.map(ch => renderChannelCard(state, ch)).join('')}</div>`
|
||||
: `<div class="mch-empty">${icon('plug', 28)}<div>${t('modelChannels.empty')}</div></div>`}
|
||||
${state.editing ? renderEditor(state) : ''}
|
||||
`
|
||||
}
|
||||
|
||||
function renderHow() {
|
||||
return `
|
||||
<div class="mch-how">
|
||||
<div style="font-weight:700;color:var(--text-primary)">${icon('lightbulb', 14)} ${t('modelChannels.howTitle')}</div>
|
||||
<ol>
|
||||
<li><b>1</b><span>${t('modelChannels.how1')}</span></li>
|
||||
<li><b>2</b><span>${t('modelChannels.how2')}</span></li>
|
||||
<li><b>3</b><span>${t('modelChannels.how3')}</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSyncLine(state, channel, target, label, supported, unsupportedHint, syncLabel) {
|
||||
if (!supported) {
|
||||
return `
|
||||
<div class="mch-sync-line">
|
||||
<div class="mch-sync-target">${label}</div>
|
||||
<span class="mch-state" title="${attr(unsupportedHint)}">${t('modelChannels.stateUnsupported')}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
const stateKey = syncStateOf(state, target, channel)
|
||||
const stateLabel = stateKey === 'synced' ? t('modelChannels.stateSynced')
|
||||
: stateKey === 'drift' ? t('modelChannels.stateDrift') : t('modelChannels.stateNever')
|
||||
return `
|
||||
<div class="mch-sync-line">
|
||||
<div class="mch-sync-target">${label} <span class="mch-state ${stateKey}">${stateLabel}</span></div>
|
||||
<button class="btn btn-xs ${stateKey === 'synced' ? 'btn-secondary' : 'btn-primary'}" type="button"
|
||||
data-action="sync" data-target="${target}" data-channel-id="${attr(channel.id)}"
|
||||
${state.syncBusy ? 'disabled' : ''}>${icon('upload', 12)} ${syncLabel}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="mch-card" data-channel-id="${attr(channel.id)}">
|
||||
<div class="mch-card-head">
|
||||
<div class="mch-card-title">${icon('plug', 16)} <span title="${attr(channel.name)}">${esc(channel.name)}</span></div>
|
||||
<span class="mch-chip">${esc(presetLabel)}</span>
|
||||
</div>
|
||||
<div class="mch-url" title="${attr(channel.baseUrl)}">${esc(channel.baseUrl || '-')}</div>
|
||||
<div class="mch-meta">
|
||||
<span class="mch-chip">${esc(channel.apiType)}</span>
|
||||
<span class="mch-chip">${icon('box', 11)} ${t('modelChannels.modelCount', { count: (channel.models || []).length })}</span>
|
||||
<span class="mch-chip ${channel.apiKeySaved ? '' : 'mch-chip-warn'}">${icon('key', 11)} ${esc(keyInfo)}</span>
|
||||
${channel.defaultModel ? `<span class="mch-chip">${icon('check', 11)} ${esc(channel.defaultModel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="mch-sync-row">
|
||||
${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'))}
|
||||
</div>
|
||||
<div class="mch-actions">
|
||||
<button class="btn btn-xs btn-secondary" type="button" data-action="edit" data-channel-id="${attr(channel.id)}">${icon('edit', 12)} ${t('common.edit')}</button>
|
||||
<button class="btn btn-xs btn-secondary" type="button" data-action="delete" data-channel-id="${attr(channel.id)}">${icon('trash', 12)} ${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderEditor(state) {
|
||||
const draft = state.editing
|
||||
const presetOptions = [
|
||||
`<option value="" ${draft.presetKey ? '' : 'selected'}>${t('modelChannels.presetCustom')}</option>`,
|
||||
...PROVIDER_PRESETS.map(p => `<option value="${attr(p.key)}" ${p.key === draft.presetKey ? 'selected' : ''}>${esc(p.label)}</option>`),
|
||||
].join('')
|
||||
const modelLines = (draft.models || []).map(m => m.id).join('\n')
|
||||
const modelIds = (draft.models || []).map(m => m.id)
|
||||
return `
|
||||
<div class="modal-overlay" id="mch-editor-overlay">
|
||||
<div class="modal" style="max-width:640px;max-height:86vh;overflow:auto">
|
||||
<div class="modal-title">${draft.isNew ? t('modelChannels.addChannel') : t('modelChannels.editChannel')}</div>
|
||||
<form id="mch-editor-form" class="mch-editor">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mch-name">${t('modelChannels.name')}</label>
|
||||
<input class="form-input" id="mch-name" value="${attr(draft.name)}" placeholder="${attr(t('modelChannels.namePlaceholder'))}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mch-preset">${t('modelChannels.preset')}</label>
|
||||
<select class="form-input" id="mch-preset">${presetOptions}</select>
|
||||
<div class="form-hint">${t('modelChannels.presetHint')}</div>
|
||||
</div>
|
||||
<div class="form-group wide">
|
||||
<label class="form-label" for="mch-base-url">${t('modelChannels.baseUrl')}</label>
|
||||
<input class="form-input" id="mch-base-url" spellcheck="false" value="${attr(draft.baseUrl)}" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mch-api-type">${t('modelChannels.apiType')}</label>
|
||||
<select class="form-input" id="mch-api-type">
|
||||
${API_TYPES.map(v => `<option value="${attr(v)}" ${v === draft.apiType ? 'selected' : ''}>${esc(v)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mch-api-key">${t('modelChannels.apiKey')}</label>
|
||||
<input class="form-input" id="mch-api-key" type="password" autocomplete="new-password" spellcheck="false"
|
||||
placeholder="${draft.apiKeySaved ? attr(t('modelChannels.keySaved', { mask: draft.apiKeyMask || '***' })) : 'sk-...'}">
|
||||
<div class="form-hint">${draft.apiKeySaved ? t('modelChannels.apiKeyHint') : ''}</div>
|
||||
</div>
|
||||
<div class="form-group wide">
|
||||
<label class="form-label" for="mch-models">${t('modelChannels.models')}</label>
|
||||
<textarea class="form-input" id="mch-models" spellcheck="false" placeholder="gpt-4o gpt-4o-mini">${esc(modelLines)}</textarea>
|
||||
<div class="form-hint" style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span>${t('modelChannels.modelsHint')}</span>
|
||||
<button class="btn btn-xs btn-secondary" type="button" data-action="fetch-models" ${state.fetchBusy ? 'disabled' : ''}>
|
||||
${icon('refresh-cw', 12)} ${state.fetchBusy ? t('modelChannels.fetching') : t('modelChannels.fetchModels')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group wide">
|
||||
<label class="form-label" for="mch-default-model">${t('modelChannels.defaultModel')}</label>
|
||||
<select class="form-input" id="mch-default-model">
|
||||
<option value="">${t('modelChannels.defaultModelNone')}</option>
|
||||
${modelIds.map(id => `<option value="${attr(id)}" ${id === draft.defaultModel ? 'selected' : ''}>${esc(id)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
<div class="modal-actions" style="margin-top:14px">
|
||||
<button class="btn btn-secondary" type="button" data-action="editor-cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" type="button" data-action="editor-save">${icon('check', 14)} ${t('modelChannels.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// 从编辑器 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 = `<option value="">${t('modelChannels.defaultModelNone')}</option>`
|
||||
+ ids.map(id => `<option value="${attr(id)}" ${id === current ? 'selected' : ''}>${esc(id)}</option>`).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)
|
||||
}
|
||||
}
|
||||
59
tests/model-channels.test.js
Normal file
59
tests/model-channels.test.js
Normal file
@@ -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 模块')
|
||||
})
|
||||
Reference in New Issue
Block a user