feat: refine WebUI access and Claude Desktop model mappings
- add a shortcut to open the management WebUI - hide redundant runtime credential identifiers - simplify health check descriptions - use existing Claude model aliases directly - preserve context capabilities from original models
This commit is contained in:
1 parent
1a33b006e2
commit
efc8811d0c
11 files changed
+265
-34
No files matched your search
+147
-13
@@ -2158,7 +2158,7 @@ async fn apply_agent_config(
|
||||
claude_desktop_model_mappings,
|
||||
)?;
|
||||
if let Some(mappings) = claude_desktop_model_mappings.as_ref() {
|
||||
ensure_claude_desktop_model_aliases(&config, mappings).await?;
|
||||
ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?;
|
||||
}
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -2224,7 +2224,7 @@ async fn reset_agent_config_to_default(
|
||||
claude_desktop_model_mappings,
|
||||
)?;
|
||||
if let Some(mappings) = claude_desktop_model_mappings.as_ref() {
|
||||
ensure_claude_desktop_model_aliases(&config, mappings).await?;
|
||||
ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?;
|
||||
}
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -2285,7 +2285,7 @@ async fn set_agent_config_enabled(
|
||||
claude_desktop_model_mappings,
|
||||
)?;
|
||||
if let Some(mappings) = claude_desktop_model_mappings.as_ref() {
|
||||
ensure_claude_desktop_model_aliases(&config, mappings).await?;
|
||||
ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?;
|
||||
}
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -2337,7 +2337,7 @@ async fn update_agent_config(
|
||||
claude_desktop_model_mappings,
|
||||
)?;
|
||||
if let Some(mappings) = claude_desktop_model_mappings.as_ref() {
|
||||
ensure_claude_desktop_model_aliases(&config, mappings).await?;
|
||||
ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?;
|
||||
}
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -4736,11 +4736,18 @@ fn build_claude_desktop_profile(
|
||||
let mappings = mappings
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ClaudeDesktopModelMappings::all(model));
|
||||
let inference_models = vec![
|
||||
let mut inference_models = vec![
|
||||
claude_desktop_inference_model(CLAUDE_DESKTOP_OPUS_MODEL_ID, &mappings.opus, models),
|
||||
claude_desktop_inference_model(CLAUDE_DESKTOP_SONNET_MODEL_ID, &mappings.sonnet, models),
|
||||
claude_desktop_inference_model(CLAUDE_DESKTOP_HAIKU_MODEL_ID, &mappings.haiku, models),
|
||||
];
|
||||
let mut seen_models = HashSet::new();
|
||||
inference_models.retain(|entry| {
|
||||
entry
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|name| seen_models.insert(name.to_ascii_lowercase()))
|
||||
});
|
||||
root.insert(
|
||||
"inferenceModels".to_string(),
|
||||
serde_json::Value::Array(inference_models),
|
||||
@@ -4753,12 +4760,34 @@ fn claude_desktop_inference_model(
|
||||
source_model: &str,
|
||||
models: &[AgentModelOption],
|
||||
) -> serde_json::Value {
|
||||
let context_window = models
|
||||
let selected = models
|
||||
.iter()
|
||||
.find(|model| model.name.eq_ignore_ascii_case(source_model))
|
||||
.and_then(|model| model.context_window);
|
||||
.find(|model| model.name.eq_ignore_ascii_case(source_model));
|
||||
let direct_alias = selected.is_some_and(|model| model.is_alias);
|
||||
let context_model = selected.and_then(|model| {
|
||||
if !model.is_alias {
|
||||
return Some(model);
|
||||
}
|
||||
model
|
||||
.alias
|
||||
.as_deref()
|
||||
.and_then(|source| {
|
||||
models
|
||||
.iter()
|
||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(source))
|
||||
})
|
||||
.or(Some(model))
|
||||
});
|
||||
let context_window = context_model.and_then(|model| model.context_window);
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert("name".to_string(), serde_json::json!(route_model));
|
||||
entry.insert(
|
||||
"name".to_string(),
|
||||
serde_json::json!(if direct_alias {
|
||||
source_model
|
||||
} else {
|
||||
route_model
|
||||
}),
|
||||
);
|
||||
if context_window.is_some_and(|window| window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW) {
|
||||
entry.insert("supports1m".to_string(), serde_json::json!(true));
|
||||
entry.insert("prefer1m".to_string(), serde_json::json!(true));
|
||||
@@ -11776,9 +11805,10 @@ async fn put_management_config_yaml(config: &GuiConfigFile, content: &str) -> Re
|
||||
async fn ensure_claude_desktop_model_aliases(
|
||||
config: &GuiConfigFile,
|
||||
mappings: &ClaudeDesktopModelMappings,
|
||||
models: &[AgentModelOption],
|
||||
) -> Result<(), String> {
|
||||
let content = fetch_management_config_yaml(config).await?;
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(&content, mappings)?;
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models)?;
|
||||
if updated != content {
|
||||
put_management_config_yaml(config, &updated).await?;
|
||||
}
|
||||
@@ -11788,6 +11818,7 @@ async fn ensure_claude_desktop_model_aliases(
|
||||
fn ensure_claude_desktop_model_aliases_in_yaml(
|
||||
content: &str,
|
||||
mappings: &ClaudeDesktopModelMappings,
|
||||
models: &[AgentModelOption],
|
||||
) -> Result<String, String> {
|
||||
let mut document = yaml_serde_edit::YamlValue::parse(content)
|
||||
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
|
||||
@@ -11801,11 +11832,50 @@ fn ensure_claude_desktop_model_aliases_in_yaml(
|
||||
(CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()),
|
||||
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, mappings.haiku.as_str()),
|
||||
] {
|
||||
ensure_claude_desktop_model_alias(root, source_model, alias)?;
|
||||
let direct_alias = models
|
||||
.iter()
|
||||
.any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias);
|
||||
if direct_alias {
|
||||
if !source_model.eq_ignore_ascii_case(alias) {
|
||||
remove_claude_desktop_model_alias(root, alias)?;
|
||||
}
|
||||
} else {
|
||||
ensure_claude_desktop_model_alias(root, source_model, alias)?;
|
||||
}
|
||||
}
|
||||
render_updated_core_yaml(&mut document, updated)
|
||||
}
|
||||
|
||||
fn remove_claude_desktop_model_alias(
|
||||
root: &mut serde_norway::Mapping,
|
||||
alias: &str,
|
||||
) -> Result<(), String> {
|
||||
for section in MODEL_ALIAS_CONFIG_SECTIONS {
|
||||
let Some(providers) = yaml_mapping_value_mut(root, section) else {
|
||||
continue;
|
||||
};
|
||||
let providers = providers
|
||||
.as_sequence_mut()
|
||||
.ok_or_else(|| format!("{section} 必须是数组"))?;
|
||||
for provider in providers {
|
||||
let Some(provider) = provider.as_mapping_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
|
||||
continue;
|
||||
};
|
||||
let models = models
|
||||
.as_sequence_mut()
|
||||
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
|
||||
models.retain(|model| {
|
||||
configured_model_identity(model)
|
||||
.is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias))
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_claude_desktop_model_alias(
|
||||
root: &mut serde_norway::Mapping,
|
||||
source_model: &str,
|
||||
@@ -18842,7 +18912,10 @@ model:
|
||||
sonnet: "gpt-5.6-sol".to_string(),
|
||||
haiku: "gpt-5.6-sol".to_string(),
|
||||
};
|
||||
let rendered = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings).unwrap();
|
||||
let available_models = test_agent_models(&["gpt-5.6-sol"]);
|
||||
let rendered =
|
||||
ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &available_models)
|
||||
.unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
|
||||
let root = value.as_mapping().unwrap();
|
||||
let providers = yaml_mapping_value(root, "openai-compatibility")
|
||||
@@ -18866,11 +18939,72 @@ model:
|
||||
CLAUDE_DESKTOP_HAIKU_MODEL_ID
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings).unwrap(),
|
||||
ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings, &available_models,)
|
||||
.unwrap(),
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_uses_selected_alias_directly_with_original_context() {
|
||||
let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-original\n - name: gpt-original\n alias: gpt-high\n - name: gpt-original\n alias: claude-opus-5\n";
|
||||
let mappings = ClaudeDesktopModelMappings {
|
||||
opus: "gpt-high".to_string(),
|
||||
sonnet: "gpt-high".to_string(),
|
||||
haiku: "gpt-high".to_string(),
|
||||
};
|
||||
let models = vec![
|
||||
AgentModelOption {
|
||||
name: "gpt-original".to_string(),
|
||||
alias: None,
|
||||
is_alias: false,
|
||||
context_window: Some(1_000_000),
|
||||
},
|
||||
AgentModelOption {
|
||||
name: "gpt-high".to_string(),
|
||||
alias: Some("gpt-original".to_string()),
|
||||
is_alias: true,
|
||||
context_window: Some(128_000),
|
||||
},
|
||||
];
|
||||
|
||||
let rendered =
|
||||
ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &models).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
|
||||
let root = value.as_mapping().unwrap();
|
||||
let providers = yaml_mapping_value(root, "openai-compatibility")
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.unwrap();
|
||||
let configured_models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models")
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.unwrap();
|
||||
let client_models = configured_models
|
||||
.iter()
|
||||
.filter_map(configured_model_identity)
|
||||
.map(|(_, client_model, _)| client_model)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(client_models, vec!["gpt-original", "gpt-high"]);
|
||||
|
||||
let profile = build_claude_desktop_profile(
|
||||
None,
|
||||
"http://127.0.0.1:8317",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-high",
|
||||
&models,
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
let profile: serde_json::Value = serde_json::from_str(&profile).unwrap();
|
||||
assert_eq!(
|
||||
profile["inferenceModels"],
|
||||
serde_json::json!([{
|
||||
"name": "gpt-high",
|
||||
"supports1m": true,
|
||||
"prefer1m": true
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_role_mappings_can_use_different_available_models() {
|
||||
let models = test_agent_models(&["gpt-5.6-sol", "deepseek-chat", "gemini-3-pro"]);
|
||||
|
||||
+3
-2
@@ -293,11 +293,13 @@ export const jaOverrides = {
|
||||
'config.webuiKey.confirmPlaceholder': '新しいキーをもう一度入力',
|
||||
'config.webuiKey.generate': '安全なキーを生成',
|
||||
'config.webuiKey.save': 'キーを保存',
|
||||
'config.webuiKey.open': 'WebUI を開く',
|
||||
'config.webuiKey.error.empty': 'WebUI キーを入力してください',
|
||||
'config.webuiKey.error.legacyDefault': '旧版の既定キー 123456 は使用できません',
|
||||
'config.webuiKey.error.tooLong': 'WebUI キーは 512 文字以内で入力してください',
|
||||
'config.webuiKey.error.invalid': 'WebUI キーに制御文字は使用できません',
|
||||
'config.webuiKey.error.mismatch': '入力した WebUI キーが一致しません',
|
||||
'config.webuiKey.error.openFailed': 'WebUI を開けませんでした:{error}',
|
||||
'config.webuiKey.notice.updated': 'WebUI キーを更新しました',
|
||||
'config.keys.title': '認証キー',
|
||||
'config.keys.count': 'キー数',
|
||||
@@ -622,7 +624,6 @@ export const jaOverrides = {
|
||||
'apiAccess.defaultUrl': '既定のアドレスを使用',
|
||||
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
|
||||
'apiAccess.priorityValue': '優先度 {priority}',
|
||||
'apiAccess.runtimeCredential': '実行時認証情報 {index}…',
|
||||
'apiAccess.enable': '接続を有効化',
|
||||
'apiAccess.disable': '接続を無効化',
|
||||
'apiAccess.status.disabled': '無効',
|
||||
@@ -630,7 +631,7 @@ export const jaOverrides = {
|
||||
'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ',
|
||||
'apiAccess.health.action': 'ヘルスチェック',
|
||||
'apiAccess.health.title': 'モデルヘルスチェック',
|
||||
'apiAccess.health.description': '{provider} · 各モデルのエンドポイントを検証',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
'apiAccess.health.warning': '各モデルへ最小限のストリーミングリクエストを 1 回送信し、最初の出力文字を受信した時点で停止します。少額の料金が発生する場合があります。',
|
||||
'apiAccess.health.search': 'モデル名またはエイリアスを検索',
|
||||
'apiAccess.health.loadingModels': 'モデル一覧を読み込んでいます…',
|
||||
|
||||
@@ -296,11 +296,13 @@ export const zhCN = {
|
||||
'config.webuiKey.confirmPlaceholder': '再次输入新密钥',
|
||||
'config.webuiKey.generate': '生成安全密钥',
|
||||
'config.webuiKey.save': '保存密钥',
|
||||
'config.webuiKey.open': '打开 WebUI',
|
||||
'config.webuiKey.error.empty': 'WebUI 密钥不能为空',
|
||||
'config.webuiKey.error.legacyDefault': '不能继续使用旧版默认密钥 123456',
|
||||
'config.webuiKey.error.tooLong': 'WebUI 密钥不能超过 512 个字符',
|
||||
'config.webuiKey.error.invalid': 'WebUI 密钥不能包含控制字符',
|
||||
'config.webuiKey.error.mismatch': '两次输入的 WebUI 密钥不一致',
|
||||
'config.webuiKey.error.openFailed': '无法打开 WebUI:{error}',
|
||||
'config.webuiKey.notice.updated': 'WebUI 密钥已更新',
|
||||
'config.keys.title': '鉴权密钥',
|
||||
'config.keys.count': '密钥数量',
|
||||
@@ -625,7 +627,6 @@ export const zhCN = {
|
||||
'apiAccess.defaultUrl': '使用默认地址',
|
||||
'apiAccess.models.summary': '模型 {count} 个 · {models}',
|
||||
'apiAccess.priorityValue': '优先级 {priority}',
|
||||
'apiAccess.runtimeCredential': '运行时凭据 {index}…',
|
||||
'apiAccess.enable': '启用接入',
|
||||
'apiAccess.disable': '停用接入',
|
||||
'apiAccess.status.disabled': '已停用',
|
||||
@@ -633,7 +634,7 @@ export const zhCN = {
|
||||
'apiAccess.toggleAria': '{remark} 接入{action}开关',
|
||||
'apiAccess.health.action': '健康检测',
|
||||
'apiAccess.health.title': '模型健康检测',
|
||||
'apiAccess.health.description': '{provider} · 逐个验证模型接口',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字后立即停止,并可能产生少量费用。',
|
||||
'apiAccess.health.search': '搜索模型名称或别名',
|
||||
'apiAccess.health.loadingModels': '正在获取模型列表…',
|
||||
@@ -1210,11 +1211,13 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.webuiKey.confirmPlaceholder': 'Enter the new key again',
|
||||
'config.webuiKey.generate': 'Generate secure key',
|
||||
'config.webuiKey.save': 'Save key',
|
||||
'config.webuiKey.open': 'Open WebUI',
|
||||
'config.webuiKey.error.empty': 'The WebUI key cannot be empty',
|
||||
'config.webuiKey.error.legacyDefault': 'The legacy default key 123456 is no longer allowed',
|
||||
'config.webuiKey.error.tooLong': 'The WebUI key cannot exceed 512 characters',
|
||||
'config.webuiKey.error.invalid': 'The WebUI key cannot contain control characters',
|
||||
'config.webuiKey.error.mismatch': 'The WebUI keys do not match',
|
||||
'config.webuiKey.error.openFailed': 'Could not open the WebUI: {error}',
|
||||
'config.webuiKey.notice.updated': 'WebUI key updated',
|
||||
'config.keys.title': 'Authentication Keys',
|
||||
'config.keys.count': 'Key count',
|
||||
@@ -1539,7 +1542,6 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.defaultUrl': 'Using default address',
|
||||
'apiAccess.models.summary': '{count} models · {models}',
|
||||
'apiAccess.priorityValue': 'Priority {priority}',
|
||||
'apiAccess.runtimeCredential': 'Runtime credential {index}…',
|
||||
'apiAccess.enable': 'Enable connection',
|
||||
'apiAccess.disable': 'Disable connection',
|
||||
'apiAccess.status.disabled': 'Disabled',
|
||||
@@ -1547,7 +1549,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.toggleAria': '{remark} connection {action} switch',
|
||||
'apiAccess.health.action': 'Health Check',
|
||||
'apiAccess.health.title': 'Model Health Check',
|
||||
'apiAccess.health.description': '{provider} · Validate each model endpoint',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token, and may incur a small charge.',
|
||||
'apiAccess.health.search': 'Search model names or aliases',
|
||||
'apiAccess.health.loadingModels': 'Loading the model list…',
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
filterAgentModels,
|
||||
filterAgentModelsByAlias,
|
||||
findAgentModel,
|
||||
resolveAgentModelForAliasMode,
|
||||
resolveAgentModelSelection,
|
||||
} from '../services/agentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
@@ -655,6 +656,14 @@ export function AgentsPage() {
|
||||
useEffect(() => {
|
||||
if (selected !== 'claude-desktop' || !selectedModel) return;
|
||||
const appliedMappings = activeStatus?.claudeDesktopModelMappings;
|
||||
if (!claudeDesktopModelMappingsDirtyRef.current && appliedMappings) {
|
||||
const appliedModels = claudeDesktopMappingRoles
|
||||
.map((role) => findAgentModel(models, appliedMappings[role.key]))
|
||||
.filter((model): model is ModelOption => model !== null);
|
||||
if (appliedModels.length === claudeDesktopMappingRoles.length) {
|
||||
setClaudeDesktopCustomMapping(appliedModels.every((model) => Boolean(model.isAlias)));
|
||||
}
|
||||
}
|
||||
setClaudeDesktopModelMappingsDraft((current) => {
|
||||
const source = claudeDesktopModelMappingsDirtyRef.current
|
||||
? current
|
||||
@@ -755,6 +764,22 @@ export function AgentsPage() {
|
||||
setClaudeDesktopModelMappingsDraft((current) => ({ ...current, [role]: model.name }));
|
||||
};
|
||||
|
||||
const changeClaudeDesktopCustomMapping = (enabled: boolean) => {
|
||||
setClaudeDesktopCustomMapping(enabled);
|
||||
setModelSelectionError('');
|
||||
setClaudeDesktopModelMappingsDraft((current) => {
|
||||
const next: ClaudeDesktopModelMappings = {
|
||||
opus: resolveAgentModelForAliasMode(models, current.opus, enabled),
|
||||
sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled),
|
||||
haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled),
|
||||
};
|
||||
if (!sameClaudeDesktopModelMappings(current, next)) {
|
||||
claudeDesktopModelMappingsDirtyRef.current = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const requireSelectedModel = () => {
|
||||
if (modelLoading) {
|
||||
setModelSelectionError(t('agents.error.modelsLoading'));
|
||||
@@ -1123,7 +1148,7 @@ export function AgentsPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={claudeDesktopCustomMapping}
|
||||
onChange={(event) => setClaudeDesktopCustomMapping(event.currentTarget.checked)}
|
||||
onChange={(event) => changeClaudeDesktopCustomMapping(event.currentTarget.checked)}
|
||||
disabled={busy || modelLoading}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
|
||||
@@ -1022,10 +1022,11 @@ export function ApiAccessPage() {
|
||||
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
|
||||
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })}</span> : null}
|
||||
</div>
|
||||
<div className="provider-row-meta">
|
||||
{row.priority === null ? null : <span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>}
|
||||
{row.authIndex ? <span title={row.authIndex}>{t('apiAccess.runtimeCredential', { index: row.authIndex.slice(0, 8) })}</span> : null}
|
||||
</div>
|
||||
{row.priority === null ? null : (
|
||||
<div className="provider-row-meta">
|
||||
<span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="provider-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
|
||||
import { useI18n } from '../i18n';
|
||||
import { webUiManagementUrl } from '../services/clientAccess';
|
||||
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
|
||||
|
||||
type CoreConfigSettings = {
|
||||
@@ -322,6 +323,16 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openWebUi = async () => {
|
||||
try {
|
||||
await invoke('open_external_url', {
|
||||
url: webUiManagementUrl(settings?.port ?? 8317),
|
||||
});
|
||||
} catch (error) {
|
||||
showNotice(t('config.webuiKey.error.openFailed', { error: String(error) }), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const changeRoutingStrategy = async (strategy: string) => {
|
||||
if (strategy === settings?.routingStrategy) {
|
||||
return;
|
||||
@@ -573,15 +584,26 @@ export function ConfigPanelPage() {
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
<h2>{t('config.webuiKey.title')}</h2>
|
||||
</div>
|
||||
{!loading && settings ? (
|
||||
<span
|
||||
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
|
||||
<div className="config-heading-actions">
|
||||
{!loading && settings ? (
|
||||
<span
|
||||
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
|
||||
>
|
||||
{settings.managementSecretConfigured
|
||||
? t('config.webuiKey.configured')
|
||||
: t('config.webuiKey.unconfigured')}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={loading || settings === null}
|
||||
onClick={() => void openWebUi()}
|
||||
title={settings ? webUiManagementUrl(settings.port) : undefined}
|
||||
>
|
||||
{settings.managementSecretConfigured
|
||||
? t('config.webuiKey.configured')
|
||||
: t('config.webuiKey.unconfigured')}
|
||||
</span>
|
||||
) : null}
|
||||
{t('config.webuiKey.open')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-management-content">
|
||||
|
||||
@@ -35,6 +35,26 @@ export function filterAgentModelsByAlias(
|
||||
return models.filter((model) => Boolean(model.isAlias) === aliasesOnly);
|
||||
}
|
||||
|
||||
export function resolveAgentModelForAliasMode(
|
||||
models: ModelOption[],
|
||||
current: string,
|
||||
aliasesOnly: boolean,
|
||||
): string {
|
||||
const candidates = filterAgentModelsByAlias(models, aliasesOnly);
|
||||
const selected = findAgentModel(models, current);
|
||||
if (selected && Boolean(selected.isAlias) === aliasesOnly) return selected.name;
|
||||
|
||||
if (selected && aliasesOnly) {
|
||||
const alias = candidates.find((candidate) => normalized(candidate.alias ?? '') === normalized(selected.name));
|
||||
if (alias) return alias.name;
|
||||
}
|
||||
if (selected?.isAlias && !aliasesOnly) {
|
||||
const original = candidates.find((candidate) => normalized(candidate.name) === normalized(selected.alias ?? ''));
|
||||
if (original) return original.name;
|
||||
}
|
||||
return candidates[0]?.name ?? '';
|
||||
}
|
||||
|
||||
export function hasExactAgentModel(models: ModelOption[], value: string): boolean {
|
||||
const query = normalized(value);
|
||||
if (!query) return false;
|
||||
|
||||
@@ -8,8 +8,15 @@ export type ClientApiProfile = {
|
||||
lanUrl: string | null;
|
||||
};
|
||||
|
||||
const safeLocalPort = (port: number) =>
|
||||
Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
|
||||
|
||||
export function webUiManagementUrl(port: number): string {
|
||||
return `http://127.0.0.1:${safeLocalPort(port)}/management.html#/login`;
|
||||
}
|
||||
|
||||
export function clientApiProfiles(port: number, lanIpv4?: string | null): ClientApiProfile[] {
|
||||
const safePort = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
|
||||
const safePort = safeLocalPort(port);
|
||||
const origin = `http://127.0.0.1:${safePort}`;
|
||||
const lanOrigin = lanIpv4?.trim() ? `http://${lanIpv4.trim()}:${safePort}` : null;
|
||||
|
||||
|
||||
@@ -1929,6 +1929,11 @@ textarea:focus-visible {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.config-heading-actions .compact-button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.config-panel-heading .state-pill {
|
||||
max-width: 108px;
|
||||
flex: none;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
filterAgentModelsByAlias,
|
||||
findAgentModel,
|
||||
hasExactAgentModel,
|
||||
resolveAgentModelForAliasMode,
|
||||
resolveAgentModelSelection,
|
||||
} from '../src/services/agentModelPicker';
|
||||
|
||||
@@ -62,5 +63,9 @@ describe('智能体模型选择器', () => {
|
||||
.toEqual(['gpt-original', 'claude-original']);
|
||||
expect(filterAgentModelsByAlias(mixedModels, true).map((model) => model.name))
|
||||
.toEqual(['gpt-high']);
|
||||
expect(resolveAgentModelForAliasMode(mixedModels, 'gpt-original', true)).toBe('gpt-high');
|
||||
expect(resolveAgentModelForAliasMode(mixedModels, 'gpt-high', false)).toBe('gpt-original');
|
||||
expect(resolveAgentModelForAliasMode(mixedModels, 'claude-original', true)).toBe('gpt-high');
|
||||
expect(resolveAgentModelForAliasMode([], 'gpt-original', true)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { clientApiProfiles } from '../src/services/clientAccess';
|
||||
import { clientApiProfiles, webUiManagementUrl } from '../src/services/clientAccess';
|
||||
|
||||
describe('客户端 API 接入信息', () => {
|
||||
it('生成 OpenAI、Claude 和 Gemini 三种正确格式', () => {
|
||||
@@ -20,4 +20,13 @@ describe('客户端 API 接入信息', () => {
|
||||
expect(openai.baseUrl).toBe('http://127.0.0.1:8317/v1');
|
||||
expect(openai.lanUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('使用当前内核端口生成 WebUI 登录地址', () => {
|
||||
expect(webUiManagementUrl(9527)).toBe(
|
||||
'http://127.0.0.1:9527/management.html#/login',
|
||||
);
|
||||
expect(webUiManagementUrl(0)).toBe(
|
||||
'http://127.0.0.1:8317/management.html#/login',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user