feat(agents): add agent configuration and thinking aliases
This commit is contained in:
1 parent
24b4d1f74e
commit
41efeebcbb
16 files changed
+8154
-224
No files matched your search
Generated
+61
@@ -419,6 +419,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"json5",
|
||||
"libc",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -432,6 +433,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.8.2",
|
||||
"toml_edit 0.20.2",
|
||||
"windows-sys 0.59.0",
|
||||
"yaml-edit",
|
||||
"yaml-serde-edit",
|
||||
@@ -1692,6 +1694,17 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -2206,6 +2219,48 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3923,6 +3978,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
json5 = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -22,6 +23,7 @@ tauri = { version = "2", features = [] }
|
||||
tokio = { version = "1", features = ["macros", "sync", "time"] }
|
||||
tokio-util = "0.7"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.20"
|
||||
yaml-serde-edit = { version = "0.1", default-features = false }
|
||||
yaml-edit = "0.2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
+4968
-128
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileKey, Gauge, LogIn, Network, ServerCog, Settings } from 'lucide-react';
|
||||
import { Bot, FileKey, Gauge, GitFork, LogIn, Network, ServerCog, Settings } from 'lucide-react';
|
||||
import appLogo from './assets/logo.jpg';
|
||||
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
|
||||
import { ConfigPanelPage } from './pages/ConfigPanel';
|
||||
@@ -8,6 +8,8 @@ import { AuthFileManagementPage } from './pages/AuthFileManagementPage';
|
||||
import { KernelPage } from './pages/Kernel';
|
||||
import { OAuthLoginPage } from './pages/ManagementPages';
|
||||
import { QuotaPage } from './pages/QuotaPage';
|
||||
import { AgentsPage } from './pages/AgentsPage';
|
||||
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
|
||||
|
||||
const pages = [
|
||||
{
|
||||
@@ -22,6 +24,12 @@ const pages = [
|
||||
icon: Settings,
|
||||
component: ConfigPanelPage,
|
||||
},
|
||||
{
|
||||
id: 'thinking-aliases',
|
||||
label: '模型别名',
|
||||
icon: GitFork,
|
||||
component: ThinkingAliasesPage,
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
label: 'OAuth',
|
||||
@@ -46,6 +54,12 @@ const pages = [
|
||||
icon: Gauge,
|
||||
component: QuotaPage,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
label: '智能体',
|
||||
icon: Bot,
|
||||
component: AgentsPage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type PageId = (typeof pages)[number]['id'];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenClaw</title>
|
||||
<defs>
|
||||
<linearGradient id="openclaw-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#openclaw-gradient)" />
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#openclaw-gradient)" />
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#openclaw-gradient)" />
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<circle cx="45" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="75" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenCode</title>
|
||||
<g clip-path="url(#opencode-clip)">
|
||||
<mask id="opencode-mask" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#opencode-mask)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD" />
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="opencode-clip">
|
||||
<rect width="240" height="300" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,783 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
LoaderCircle,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
import hermesIcon from '../assets/icons/hermes.png';
|
||||
import openclawIcon from '../assets/icons/openclaw.svg';
|
||||
import opencodeIcon from '../assets/icons/opencode.svg';
|
||||
import {
|
||||
agentModelAlias,
|
||||
filterAgentModels,
|
||||
findAgentModel,
|
||||
resolveAgentModelSelection,
|
||||
} from '../services/agentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
|
||||
type AgentClientId =
|
||||
| 'claude-code'
|
||||
| 'claude-desktop'
|
||||
| 'codex'
|
||||
| 'opencode'
|
||||
| 'openclaw'
|
||||
| 'hermes';
|
||||
|
||||
type AgentModificationState = 'inactive' | 'active' | 'conflict' | 'recovery';
|
||||
|
||||
type AgentConfigStatus = {
|
||||
id: AgentClientId;
|
||||
name: string;
|
||||
supportedPlatform: boolean;
|
||||
installed: boolean;
|
||||
executablePath: string | null;
|
||||
launchTargets: AgentLaunchTarget[];
|
||||
version: string | null;
|
||||
configValid: boolean;
|
||||
configured: boolean;
|
||||
currentModel: string | null;
|
||||
modificationEnabled: boolean;
|
||||
modificationState: AgentModificationState;
|
||||
backupAvailable: boolean;
|
||||
appliedModel: string | null;
|
||||
warnings: string[];
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type AgentLaunchTarget = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type AgentConfigActionResult = {
|
||||
outcome: 'enabled' | 'disabled' | 'updated' | 'restore-conflict';
|
||||
enabled: boolean;
|
||||
model: string | null;
|
||||
changedFiles: string[];
|
||||
conflictFiles: string[];
|
||||
};
|
||||
|
||||
type AgentDefinition = {
|
||||
id: AgentClientId;
|
||||
name: string;
|
||||
icon?: string;
|
||||
Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const agentDefinitions: AgentDefinition[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
icon: claudeIcon,
|
||||
description: '使用 CPA 的 Anthropic 兼容接口',
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
icon: claudeIcon,
|
||||
description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
icon: codexIcon,
|
||||
description: '使用 CPA Responses 接口',
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
icon: opencodeIcon,
|
||||
description: '使用 CPA OpenAI Compatible provider',
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
icon: openclawIcon,
|
||||
description: '使用 CPA 模型供应商和默认模型',
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
name: 'Hermes Agent',
|
||||
icon: hermesIcon,
|
||||
description: '使用 CPA custom provider',
|
||||
},
|
||||
];
|
||||
|
||||
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
|
||||
|
||||
const readAgentModelSelections = (): Partial<Record<AgentClientId, string>> => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const payload = window.localStorage.getItem(AGENT_MODEL_SELECTIONS_KEY);
|
||||
if (!payload) return {};
|
||||
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
|
||||
const value = parsed[agent.id];
|
||||
if (typeof value === 'string' && value.trim()) result[agent.id] = value.trim();
|
||||
return result;
|
||||
}, {});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeAgentModelSelections = (
|
||||
selections: Partial<Record<AgentClientId, string>>,
|
||||
) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(AGENT_MODEL_SELECTIONS_KEY, JSON.stringify(selections));
|
||||
} catch {
|
||||
// Local storage can be unavailable in hardened webviews; the in-memory selection still works.
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileAgentModelSelections = (
|
||||
current: Partial<Record<AgentClientId, string>>,
|
||||
models: ModelOption[],
|
||||
) => {
|
||||
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
|
||||
const existing = current[agent.id] ?? '';
|
||||
result[agent.id] = resolveAgentModelSelection(models, existing);
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
|
||||
if (definition.icon) {
|
||||
return <img src={definition.icon} alt="" className="provider-logo" />;
|
||||
}
|
||||
const Icon = definition.Icon ?? Bot;
|
||||
return <Icon size={size} aria-hidden />;
|
||||
}
|
||||
|
||||
const listStatusText = (status: AgentConfigStatus | undefined) => {
|
||||
if (!status) return '正在检测';
|
||||
if (!status.supportedPlatform) return '当前平台不支持';
|
||||
if (!status.installed) return '未检测到安装';
|
||||
if (status.modificationState === 'conflict') return '配置冲突 · 等待恢复';
|
||||
if (status.modificationState === 'recovery') return '操作未完成 · 可恢复';
|
||||
if (status.modificationEnabled) return `已修改 · ${status.appliedModel ?? '未记录模型'}`;
|
||||
return status.version ? `已安装 · ${status.version}` : '已安装 · 保持原配置';
|
||||
};
|
||||
|
||||
type AgentModelPickerProps = {
|
||||
models: ModelOption[];
|
||||
value: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
function AgentModelPicker({
|
||||
models,
|
||||
value,
|
||||
loading,
|
||||
error,
|
||||
disabled,
|
||||
onChange,
|
||||
onRefresh,
|
||||
}: AgentModelPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]);
|
||||
const choices = useMemo(
|
||||
() => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })),
|
||||
[visibleModels],
|
||||
);
|
||||
const selectedAlias = agentModelAlias(models, value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch('');
|
||||
const selectedIndex = filterAgentModels(models, '').findIndex(
|
||||
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
|
||||
);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0)));
|
||||
}, [choices.length]);
|
||||
|
||||
const choose = (name: string) => {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const moveActive = (offset: number) => {
|
||||
if (choices.length === 0) return;
|
||||
setActiveIndex((current) => (current + offset + choices.length) % choices.length);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (event.key === 'Enter' && choices[activeIndex]) {
|
||||
event.preventDefault();
|
||||
choose(choices[activeIndex].name);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`agent-model-picker ${open ? 'open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-model-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={(event) => {
|
||||
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong title={value || undefined}>
|
||||
{value || (loading ? '正在读取模型' : error ? '获取模型失败' : models.length ? '选择模型' : '无可选模型')}
|
||||
</strong>
|
||||
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
|
||||
</span>
|
||||
<ChevronDown size={17} aria-hidden />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="agent-model-dropdown">
|
||||
<div className="agent-model-search">
|
||||
<Search size={15} aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="搜索模型名称或别名"
|
||||
role="combobox"
|
||||
aria-controls="agent-model-listbox"
|
||||
aria-expanded="true"
|
||||
/>
|
||||
{search ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setActiveIndex(0);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
title="清空搜索"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title="刷新模型">
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="agent-model-list" id="agent-model-listbox" role="listbox">
|
||||
{loading && models.length === 0 ? (
|
||||
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />正在获取模型</div>
|
||||
) : error && models.length === 0 ? (
|
||||
<div className="agent-model-empty error"><strong>获取模型失败</strong><span>{error}</span></div>
|
||||
) : choices.length === 0 ? (
|
||||
<div className="agent-model-empty">
|
||||
<strong>{search.trim() ? '没有匹配的模型' : '暂时没有可用模型'}</strong>
|
||||
<span>{search.trim() ? '尝试其他关键词' : '请先接入可用模型后刷新'}</span>
|
||||
</div>
|
||||
) : choices.map((choice, index) => {
|
||||
const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`agent-model-option ${selected ? 'selected' : ''} ${index === activeIndex ? 'active' : ''}`}
|
||||
key={choice.name}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(choice.name)}
|
||||
>
|
||||
<span>
|
||||
<strong title={choice.name}>{choice.name}</strong>
|
||||
<small>{choice.alias || '可用模型'}</small>
|
||||
</span>
|
||||
{selected ? <Check size={16} aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="agent-model-dropdown-footer">
|
||||
<span>{models.length} 个可用模型</span>
|
||||
{error && models.length > 0 ? <span className="error">刷新失败,显示上次结果</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [selected, setSelected] = useState<AgentClientId>('claude-code');
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
|
||||
readAgentModelSelections,
|
||||
);
|
||||
const [launchTargetByClient, setLaunchTargetByClient] = useState<Partial<Record<AgentClientId, string>>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [modelError, setModelError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [restoreConflict, setRestoreConflict] = useState<string[] | null>(null);
|
||||
|
||||
const loadStatuses = useCallback(async () => {
|
||||
const nextStatuses = await invoke<AgentConfigStatus[]>('get_agent_config_statuses');
|
||||
setStatuses(nextStatuses);
|
||||
}, []);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
setModelLoading(true);
|
||||
setModelError('');
|
||||
try {
|
||||
const nextModels = await invoke<ModelOption[]>('get_agent_models');
|
||||
setModels(nextModels);
|
||||
setModelByClient((current) => {
|
||||
const next = reconcileAgentModelSelections(current, nextModels);
|
||||
writeAgentModelSelections(next);
|
||||
return next;
|
||||
});
|
||||
} catch (requestError) {
|
||||
setModelError(String(requestError));
|
||||
} finally {
|
||||
setModelLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await Promise.all([loadStatuses(), loadModels()]);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadModels, loadStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
|
||||
(next, definition) => {
|
||||
const targets = statuses.find((status) => status.id === definition.id)?.launchTargets ?? [];
|
||||
const previous = current[definition.id] ?? '';
|
||||
next[definition.id] = targets.some((target) => target.id === previous)
|
||||
? previous
|
||||
: targets[0]?.id ?? '';
|
||||
return next;
|
||||
},
|
||||
{},
|
||||
));
|
||||
}, [statuses]);
|
||||
|
||||
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
|
||||
?? agentDefinitions[0];
|
||||
const activeStatus = statuses.find((status) => status.id === selected) ?? null;
|
||||
const savedSelectedModel = modelByClient[selected] ?? '';
|
||||
const selectedModelOption = findAgentModel(models, savedSelectedModel);
|
||||
const selectedModel = selectedModelOption?.name ?? '';
|
||||
const activeLaunchTargets = activeStatus?.launchTargets ?? [];
|
||||
const selectedLaunchTargetId = launchTargetByClient[selected] ?? activeLaunchTargets[0]?.id ?? '';
|
||||
const selectedLaunchTarget = activeLaunchTargets.find(
|
||||
(target) => target.id === selectedLaunchTargetId,
|
||||
) ?? activeLaunchTargets[0] ?? null;
|
||||
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
|
||||
const draftChanged = Boolean(
|
||||
activeStatus?.modificationEnabled
|
||||
&& selectedModel.trim()
|
||||
&& selectedModel.trim() !== appliedModel.trim(),
|
||||
);
|
||||
const canEnable = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& activeStatus.configValid
|
||||
&& !modelLoading
|
||||
&& selectedModelOption,
|
||||
);
|
||||
const canLaunch = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& selectedLaunchTarget,
|
||||
);
|
||||
|
||||
const refreshModels = () => {
|
||||
void loadModels();
|
||||
};
|
||||
|
||||
const selectModel = (value: string) => {
|
||||
const model = findAgentModel(models, value);
|
||||
if (!model) return;
|
||||
setModelByClient((current) => {
|
||||
const next = { ...current, [selected]: model.name };
|
||||
writeAgentModelSelections(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const requireSelectedModel = () => {
|
||||
if (modelLoading) {
|
||||
setError('模型列表仍在加载,请稍后再试');
|
||||
return null;
|
||||
}
|
||||
if (models.length === 0) {
|
||||
setError(modelError || '当前没有可选模型,无法修改智能体配置');
|
||||
return null;
|
||||
}
|
||||
const model = findAgentModel(models, selectedModel);
|
||||
if (!model) {
|
||||
setError('当前选择已不在可用模型列表中,请刷新模型后重新选择');
|
||||
return null;
|
||||
}
|
||||
return model.name;
|
||||
};
|
||||
|
||||
const setModificationEnabled = async (enabled: boolean, forceRestore = false) => {
|
||||
const model = enabled ? requireSelectedModel() : selectedModel.trim();
|
||||
if (enabled && !model) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const result = await invoke<AgentConfigActionResult>('set_agent_config_enabled', {
|
||||
client: selected,
|
||||
model: model ?? '',
|
||||
enabled,
|
||||
forceRestore,
|
||||
});
|
||||
if (result.outcome === 'restore-conflict') {
|
||||
setRestoreConflict(result.conflictFiles);
|
||||
return;
|
||||
}
|
||||
setRestoreConflict(null);
|
||||
setNotice(result.enabled
|
||||
? `${activeDefinition.name} 已启用配置修改,请重启客户端`
|
||||
: `${activeDefinition.name} 的原配置已恢复,请重启客户端`);
|
||||
await loadStatuses();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const launchAgent = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
let appliedBeforeLaunch = false;
|
||||
const shouldSyncCodexCatalog = Boolean(
|
||||
selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption,
|
||||
);
|
||||
if (draftChanged || shouldSyncCodexCatalog) {
|
||||
if (activeStatus?.modificationState !== 'active') {
|
||||
throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型');
|
||||
}
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
await invoke<AgentConfigActionResult>('update_agent_config', {
|
||||
client: selected,
|
||||
model,
|
||||
});
|
||||
appliedBeforeLaunch = true;
|
||||
}
|
||||
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
|
||||
setNotice(appliedBeforeLaunch
|
||||
? draftChanged
|
||||
? `${activeDefinition.name} 已应用所选模型并启动`
|
||||
: `${activeDefinition.name} 已同步模型目录并启动`
|
||||
: `${activeDefinition.name} 已启动`);
|
||||
if (appliedBeforeLaunch) await loadStatuses();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = loading
|
||||
? '检测中'
|
||||
: !activeStatus?.supportedPlatform
|
||||
? '平台不支持'
|
||||
: !activeStatus.installed
|
||||
? '未安装'
|
||||
: activeStatus.modificationState === 'conflict'
|
||||
? '配置冲突'
|
||||
: activeStatus.modificationState === 'recovery'
|
||||
? '需要恢复'
|
||||
: activeStatus.modificationEnabled
|
||||
? '已修改配置'
|
||||
: '保持原配置';
|
||||
const statusTone = activeStatus?.modificationState === 'conflict'
|
||||
|| activeStatus?.modificationState === 'recovery'
|
||||
|| !activeStatus?.supportedPlatform
|
||||
? 'error'
|
||||
: activeStatus?.modificationEnabled
|
||||
? 'success'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<section className="page management-page agents-page">
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Agent Clients</span>
|
||||
<h1>智能体配置</h1>
|
||||
</div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
重新检测
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="agent-feedback-slot" aria-live="polite">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-workbench">
|
||||
<aside className="panel agent-client-list">
|
||||
<div className="agent-list-heading">
|
||||
<Bot size={18} />
|
||||
<div><strong>本机客户端</strong><span>选择需要管理的智能体</span></div>
|
||||
</div>
|
||||
<div className="agent-list-items">
|
||||
{agentDefinitions.map((agent) => {
|
||||
const status = statuses.find((item) => item.id === agent.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={selected === agent.id ? 'active' : ''}
|
||||
key={agent.id}
|
||||
onClick={() => setSelected(agent.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
|
||||
<span><strong>{agent.name}</strong><small>{listStatusText(status)}</small></span>
|
||||
<i
|
||||
className={status?.modificationEnabled ? 'configured' : status?.installed ? 'installed' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="panel agent-config-panel">
|
||||
<div className="agent-config-heading">
|
||||
<div className="agent-config-title">
|
||||
<span className="agent-logo"><AgentMark definition={activeDefinition} size={24} /></span>
|
||||
<div><h2>{activeDefinition.name}</h2><span>{activeDefinition.description}</span></div>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`}>{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="agent-config-message-slot">
|
||||
{activeStatus?.error ? <div className="management-alert error">{activeStatus.error}</div> : null}
|
||||
{activeStatus?.warnings.length && !activeStatus.error ? (
|
||||
<div className="agent-warning-line">{activeStatus.warnings.join(';')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-status-grid">
|
||||
<div>
|
||||
<span><BadgeCheck size={14} />安装状态</span>
|
||||
<strong>{activeStatus?.installed ? '已检测到客户端' : '未检测到客户端'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>客户端版本</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? '未获取'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>使用模型</strong><span>只能选择内核当前实际开放的模型</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">尚未应用</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
<span className="agent-model-hint">
|
||||
{modelLoading
|
||||
? '正在读取可用模型'
|
||||
: modelError && models.length === 0
|
||||
? modelError
|
||||
: models.length === 0
|
||||
? '无可选模型,不能开启或更新配置'
|
||||
: activeStatus?.modificationEnabled
|
||||
? `当前已应用:${appliedModel || '未记录模型'}`
|
||||
: `${models.length} 个可用模型,首次默认选择第一项`}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className={`agent-modification-switch ${activeStatus?.modificationEnabled ? 'enabled' : ''}`}>
|
||||
<div>
|
||||
<strong>修改智能体配置</strong>
|
||||
<span>{activeStatus?.modificationEnabled
|
||||
? activeStatus.modificationState === 'conflict'
|
||||
? '配置已被外部修改,关闭时需要确认恢复'
|
||||
: activeStatus.modificationState === 'recovery'
|
||||
? '上次操作未完成,关闭开关可尝试恢复原配置'
|
||||
: '原配置已备份,当前由 CPA 管理'
|
||||
: '开启后先备份原配置,再写入 CPA 配置'}</span>
|
||||
</div>
|
||||
<label className="switch-control" title="修改智能体配置">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(activeStatus?.modificationEnabled)}
|
||||
disabled={busy || (!activeStatus?.modificationEnabled && !canEnable)}
|
||||
onChange={(event) => void setModificationEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="agent-config-footer">
|
||||
<div>
|
||||
{activeStatus?.modificationEnabled ? <Check size={16} /> : <Sparkles size={16} />}
|
||||
<span>{activeStatus?.modificationEnabled
|
||||
? draftChanged
|
||||
? '模型选择已变化,启动时会先应用新模型'
|
||||
: '关闭开关会恢复开启前的原始配置;可直接启动智能体'
|
||||
: !activeStatus?.supportedPlatform
|
||||
? '当前系统无法配置该客户端'
|
||||
: !activeStatus.installed
|
||||
? '请先安装客户端并重新检测'
|
||||
: activeStatus.launchTargets.length === 0
|
||||
? '检测到配置文件,但没有找到客户端命令'
|
||||
: !activeStatus.configValid
|
||||
? '原配置格式异常,无法安全修改'
|
||||
: '未开启修改配置时,将使用客户端原配置启动'}</span>
|
||||
</div>
|
||||
<div className="agent-launch-actions">
|
||||
{activeLaunchTargets.length > 1 ? (
|
||||
<div className="agent-launch-targets" aria-label="Codex 启动方式">
|
||||
{activeLaunchTargets.map((target) => (
|
||||
<button
|
||||
type="button"
|
||||
className={target.id === selectedLaunchTarget?.id ? 'active' : ''}
|
||||
key={target.id}
|
||||
onClick={() => setLaunchTargetByClient((current) => ({
|
||||
...current,
|
||||
[selected]: target.id,
|
||||
}))}
|
||||
disabled={busy}
|
||||
title={target.detail}
|
||||
>
|
||||
{target.label.replace('Codex ', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void launchAgent()}
|
||||
disabled={
|
||||
busy
|
||||
|| !canLaunch
|
||||
|| (draftChanged && activeStatus?.modificationState !== 'active')
|
||||
}
|
||||
title={selectedLaunchTarget?.detail}
|
||||
>
|
||||
{busy ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
|
||||
{busy ? '启动中' : selectedLaunchTarget ? `启动 ${selectedLaunchTarget.label}` : '无法启动'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{restoreConflict ? (
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-restore-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-restore-title">配置已发生外部变化</h2></div>
|
||||
</div>
|
||||
<p>
|
||||
开启修改配置后,有 {restoreConflict.length} 个配置文件又被其他程序修改。
|
||||
继续恢复会使用开启前的备份覆盖这些变化。
|
||||
</p>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setRestoreConflict(null)} disabled={busy}>取消</button>
|
||||
<button type="button" className="danger-button" onClick={() => void setModificationEnabled(false, true)} disabled={busy}>
|
||||
{busy ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
确认强制恢复
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+117
-68
@@ -44,7 +44,6 @@ export type ProviderCategory = ProviderSection | 'deepseek';
|
||||
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
|
||||
export const OPENAI_THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
|
||||
export const DEEPSEEK_THINKING_LEVELS = OPENAI_THINKING_LEVELS;
|
||||
export type ThinkingLevel = (typeof OPENAI_THINKING_LEVELS)[number];
|
||||
|
||||
type ProviderDefinition = {
|
||||
id: ProviderCategory;
|
||||
@@ -81,7 +80,7 @@ export type ProviderDraft = {
|
||||
disableCooling?: boolean;
|
||||
websockets?: boolean;
|
||||
testModel?: string;
|
||||
thinkingLevels?: ThinkingLevel[];
|
||||
thinkingLevels?: string[];
|
||||
disabled?: boolean;
|
||||
cloakMode?: string;
|
||||
cloakStrictMode?: boolean;
|
||||
@@ -275,6 +274,9 @@ export const modelSelectionForDiscovery = (
|
||||
);
|
||||
};
|
||||
|
||||
export const allModelSelectionForDiscovery = (models: ModelOption[]) =>
|
||||
new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
|
||||
const entries = Array.isArray(current) ? current.filter(isRecord) : [];
|
||||
const keys = apiKey
|
||||
@@ -295,19 +297,19 @@ const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const thinkingLevelsFromModels = (models: ModelOption[]): ThinkingLevel[] => {
|
||||
const levels = new Set<ThinkingLevel>();
|
||||
const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
|
||||
const levels: string[] = [];
|
||||
models.forEach((model) => {
|
||||
const configured = model.thinking?.levels;
|
||||
if (!Array.isArray(configured)) return;
|
||||
configured.forEach((level) => {
|
||||
const normalized = String(level).trim().toLowerCase();
|
||||
if (OPENAI_THINKING_LEVELS.includes(normalized as ThinkingLevel)) {
|
||||
levels.add(normalized as ThinkingLevel);
|
||||
if (normalized && !levels.includes(normalized)) {
|
||||
levels.push(normalized);
|
||||
}
|
||||
});
|
||||
});
|
||||
return OPENAI_THINKING_LEVELS.filter((level) => levels.has(level));
|
||||
return levels;
|
||||
};
|
||||
|
||||
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
|
||||
@@ -377,15 +379,14 @@ const deepSeekDefaultModels = (): ModelOption[] => [
|
||||
|
||||
export const createProviderDraft = (category: ProviderCategory): ProviderDraft => {
|
||||
const draft = emptyProviderDraft();
|
||||
const withThinking = definitionFor(category).openAi
|
||||
? { ...draft, thinkingLevels: [...OPENAI_THINKING_LEVELS] }
|
||||
: draft;
|
||||
if (category !== 'deepseek') return withThinking;
|
||||
if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [] };
|
||||
if (category !== 'deepseek') return draft;
|
||||
return {
|
||||
...withThinking,
|
||||
...draft,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: DEEPSEEK_BASE_URL,
|
||||
models: deepSeekDefaultModels(),
|
||||
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -394,7 +395,9 @@ export const applyProviderPreset = (
|
||||
draft: ProviderDraft,
|
||||
): ProviderDraft => {
|
||||
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft;
|
||||
const levels = draft.thinkingLevels;
|
||||
const levels = category === 'deepseek'
|
||||
? [...DEEPSEEK_THINKING_LEVELS]
|
||||
: draft.thinkingLevels;
|
||||
return {
|
||||
...draft,
|
||||
models: draft.models.map((model) => {
|
||||
@@ -625,7 +628,10 @@ export function ApiAccessPage() {
|
||||
|
||||
const openEdit = (row: ProviderRow) => {
|
||||
setEditingRow(row);
|
||||
setDialogDraft(draftFromRow(row));
|
||||
const draft = draftFromRow(row);
|
||||
setDialogDraft(activeCategory === 'deepseek'
|
||||
? { ...draft, thinkingLevels: [...DEEPSEEK_THINKING_LEVELS] }
|
||||
: draft);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -652,10 +658,11 @@ export function ApiAccessPage() {
|
||||
return false;
|
||||
}
|
||||
let baseUrl = preparedDraft.baseUrl.trim();
|
||||
let providerHeaders: Record<string, string> = {};
|
||||
try {
|
||||
if (baseUrl) baseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (baseUrlRequired && !baseUrl) throw new Error(`${definition.label} 接入必须填写 Base URL`);
|
||||
parseProviderHeaders(preparedDraft.headersText ?? '');
|
||||
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
return false;
|
||||
@@ -663,6 +670,23 @@ export function ApiAccessPage() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
let draftToSave = { ...preparedDraft, baseUrl };
|
||||
if (definition.openAi && draftToSave.models.length === 0) {
|
||||
const fetchedModels = await fetchModels(
|
||||
'openai',
|
||||
baseUrl,
|
||||
parsedApiKeys[0],
|
||||
editingRow?.authIndex,
|
||||
providerHeaders,
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error('没有发现可放行的模型,请确认 Base URL 和 API 密钥');
|
||||
}
|
||||
draftToSave = applyProviderPreset(activeCategory, {
|
||||
...draftToSave,
|
||||
models: fetchedModels,
|
||||
});
|
||||
}
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const current = sectionRecordsFromConfig(latestConfig, activeSection);
|
||||
let nextList: Record<string, unknown>[];
|
||||
@@ -676,7 +700,7 @@ export function ApiAccessPage() {
|
||||
}
|
||||
const nextRecord = buildProviderRecord(
|
||||
activeSection,
|
||||
{ ...preparedDraft, baseUrl },
|
||||
draftToSave,
|
||||
current[targetIndex],
|
||||
);
|
||||
nextList = current.map((record, index) =>
|
||||
@@ -692,7 +716,7 @@ export function ApiAccessPage() {
|
||||
if (duplicate) throw new Error('相同的接入配置已经存在');
|
||||
nextList = [
|
||||
...current,
|
||||
buildProviderRecord(activeSection, { ...preparedDraft, baseUrl }),
|
||||
buildProviderRecord(activeSection, draftToSave),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -846,7 +870,6 @@ export function ApiAccessPage() {
|
||||
<div className="provider-row-main">
|
||||
<div className="provider-row-title">
|
||||
<strong title={row.name}>{row.name}</strong>
|
||||
{row.disabled ? <span className="state-pill error">已停用</span> : <span className="state-pill success">已启用</span>}
|
||||
</div>
|
||||
<code title={definitionFor(row.section).openAi ? `${row.apiKeys.length} 个密钥` : undefined}>
|
||||
{definitionFor(row.section).openAi && row.apiKeys.length > 1
|
||||
@@ -861,9 +884,19 @@ export function ApiAccessPage() {
|
||||
{row.authIndex ? <span title={row.authIndex}>运行时凭据 {row.authIndex.slice(0, 8)}…</span> : null}
|
||||
</div>
|
||||
<div className="provider-row-actions">
|
||||
<button type="button" className="icon-button quiet" onClick={() => void toggleProvider(row)} disabled={busy} title={row.disabled ? '启用' : '停用'}>
|
||||
{row.disabled ? <Check size={16} /> : <X size={16} />}
|
||||
</button>
|
||||
<label className="provider-enabled-control" title={row.disabled ? '启用接入' : '停用接入'}>
|
||||
<span>{row.disabled ? '已停用' : '已启用'}</span>
|
||||
<span className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!row.disabled}
|
||||
onChange={() => void toggleProvider(row)}
|
||||
disabled={busy}
|
||||
aria-label={`${row.name} 接入${row.disabled ? '启用' : '停用'}开关`}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title="编辑">
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
@@ -917,7 +950,7 @@ function ApiProviderDialog({
|
||||
const [discoveredModels, setDiscoveredModels] = useState<ModelOption[]>([]);
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [modelSelectionApplied, setModelSelectionApplied] = useState(false);
|
||||
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
|
||||
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
|
||||
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
|
||||
);
|
||||
@@ -956,19 +989,28 @@ function ApiProviderDialog({
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const toggleThinkingLevel = (level: ThinkingLevel) => {
|
||||
const addThinkingLevel = () => {
|
||||
const level = thinkingLevelInput.trim().toLowerCase();
|
||||
if (!level) return;
|
||||
setDraft((current) => {
|
||||
const selected = new Set(current.thinkingLevels ?? []);
|
||||
if (selected.has(level)) selected.delete(level);
|
||||
else selected.add(level);
|
||||
const levels = current.thinkingLevels ?? [];
|
||||
if (levels.some((item) => item.toLowerCase() === level)) return current;
|
||||
return {
|
||||
...current,
|
||||
thinkingLevels: OPENAI_THINKING_LEVELS.filter((item) => selected.has(item)),
|
||||
thinkingLevels: [...levels, level],
|
||||
};
|
||||
});
|
||||
setThinkingLevelInput('');
|
||||
};
|
||||
|
||||
const discoverModels = async (initializeSelection = false) => {
|
||||
const removeThinkingLevel = (level: string) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
thinkingLevels: (current.thinkingLevels ?? []).filter((item) => item !== level),
|
||||
}));
|
||||
};
|
||||
|
||||
const discoverModels = async () => {
|
||||
const baseUrlRequired =
|
||||
activeSection === 'codex-api-key' || activeSection === 'openai-compatibility';
|
||||
if (baseUrlRequired && !draft.baseUrl.trim()) {
|
||||
@@ -998,18 +1040,7 @@ function ApiProviderDialog({
|
||||
{ ...draft, models: fetchedModels },
|
||||
).models;
|
||||
setDiscoveredModels(models);
|
||||
if (initializeSelection) {
|
||||
setSelectedModelNames(
|
||||
!editingRow && !modelSelectionApplied
|
||||
? new Set(models.map((model) => model.name.toLowerCase()))
|
||||
: modelSelectionForDiscovery(
|
||||
activeSection,
|
||||
draft.models,
|
||||
models,
|
||||
draft.excludedModelsText ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
setSelectedModelNames(allModelSelectionForDiscovery(models));
|
||||
if (!models.length) setModelError('未发现可用模型');
|
||||
} catch (requestError) {
|
||||
setDiscoveredModels([]);
|
||||
@@ -1029,7 +1060,7 @@ function ApiProviderDialog({
|
||||
setModelSearch('');
|
||||
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
|
||||
setModelDiscoveryOpen(true);
|
||||
void discoverModels(true);
|
||||
void discoverModels();
|
||||
};
|
||||
|
||||
const toggleModelSelection = (model: ModelOption) => {
|
||||
@@ -1055,13 +1086,9 @@ function ApiProviderDialog({
|
||||
};
|
||||
|
||||
const applyModelSelection = () => {
|
||||
const useProviderDefault = activeSection !== 'openai-compatibility'
|
||||
&& draft.models.length === 0
|
||||
&& discoveredModels.length > 0
|
||||
&& discoveredModels.every((model) => selectedModelNames.has(model.name.toLowerCase()));
|
||||
const models = useProviderDefault
|
||||
? []
|
||||
: modelOptions.filter((model) => selectedModelNames.has(model.name.toLowerCase()));
|
||||
const models = modelOptions.filter((model) =>
|
||||
selectedModelNames.has(model.name.toLowerCase()),
|
||||
);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
models,
|
||||
@@ -1073,7 +1100,6 @@ function ApiProviderDialog({
|
||||
selectedModelNames,
|
||||
),
|
||||
}));
|
||||
setModelSelectionApplied(true);
|
||||
setModelDiscoveryOpen(false);
|
||||
};
|
||||
|
||||
@@ -1089,14 +1115,14 @@ function ApiProviderDialog({
|
||||
: hasModelExclusions
|
||||
? '已配置模型限制'
|
||||
: activeSection === 'openai-compatibility'
|
||||
? '获取模型后默认全部开放'
|
||||
? '保存时默认开放全部模型'
|
||||
: '使用上游默认模型';
|
||||
const modelSummaryDetail = draft.models.length > 0
|
||||
? draft.models.slice(0, 3).map((model) => model.name).join('、')
|
||||
: hasModelExclusions
|
||||
? '未勾选的模型不会出现在开放列表'
|
||||
: activeSection === 'openai-compatibility'
|
||||
? '可在模型列表中取消不希望开放的项目'
|
||||
? '保存接入时会自动获取模型,也可提前取消不需要的项目'
|
||||
: '当前开放上游全部模型';
|
||||
|
||||
return (
|
||||
@@ -1133,28 +1159,51 @@ function ApiProviderDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{definition.openAi ? (
|
||||
{activeCategory === 'deepseek' ? (
|
||||
<div className="thinking-level-config">
|
||||
<div className="thinking-level-heading">
|
||||
<strong>内置思考等级</strong>
|
||||
<span>自动应用到当前开放的全部模型</span>
|
||||
</div>
|
||||
<div className="thinking-level-tags readonly">
|
||||
{DEEPSEEK_THINKING_LEVELS.map((level) => <span key={level}>{level}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeCategory === 'openai-compatibility' ? (
|
||||
<div className="thinking-level-config">
|
||||
<div className="thinking-level-heading">
|
||||
<strong>思考等级</strong>
|
||||
<span>应用到当前开放的全部模型</span>
|
||||
<span>按上游支持情况自行添加</span>
|
||||
</div>
|
||||
<div className="thinking-level-options">
|
||||
{OPENAI_THINKING_LEVELS.map((level) => {
|
||||
const checked = draft.thinkingLevels?.includes(level) ?? false;
|
||||
return (
|
||||
<label className={checked ? 'selected' : ''} key={level}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleThinkingLevel(level)}
|
||||
/>
|
||||
<span>{level}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="thinking-level-entry">
|
||||
<input
|
||||
value={thinkingLevelInput}
|
||||
onChange={(event) => setThinkingLevelInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addThinkingLevel();
|
||||
}
|
||||
}}
|
||||
placeholder="例如 low、medium 或自定义等级"
|
||||
/>
|
||||
<button type="button" className="secondary-button compact-button" onClick={addThinkingLevel} disabled={!thinkingLevelInput.trim()}>
|
||||
<Plus size={14} />添加
|
||||
</button>
|
||||
</div>
|
||||
{(draft.thinkingLevels?.length ?? 0) > 0 ? (
|
||||
<div className="thinking-level-tags">
|
||||
{draft.thinkingLevels?.map((level) => (
|
||||
<span key={level}>
|
||||
{level}
|
||||
<button type="button" onClick={() => removeThinkingLevel(level)} title={`删除 ${level}`} aria-label={`删除思考等级 ${level}`}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : <small className="thinking-level-empty">未添加时不写入 thinking.levels</small>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="model-config-card">
|
||||
<div className="model-config-heading">
|
||||
|
||||
@@ -528,6 +528,7 @@ export function AuthFileManagementPage() {
|
||||
<span>共 {oauthModels.length} 个 · 已开放 {openOauthModelNames.size} 个</span>
|
||||
<div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? '关闭当前' : '开放当前'}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set(oauthModels.map((model) => model.id.toLowerCase())))} disabled={oauthModelLoading || oauthModels.length === 0 || openOauthModelNames.size === oauthModels.length}>全部开放</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>全部关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
Check,
|
||||
GitFork,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
type PresetThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
|
||||
type ThinkingAliasEntry = {
|
||||
sourceModel: string;
|
||||
alias: string;
|
||||
effort: string | null;
|
||||
provider: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
type ThinkingAliasSource = {
|
||||
id: string;
|
||||
model: string;
|
||||
displayName: string | null;
|
||||
provider: string;
|
||||
kind: string;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
const effortOptions: { value: PresetThinkingEffort; label: string; hint: string }[] = [
|
||||
{ value: 'low', label: 'Low', hint: '轻量' },
|
||||
{ value: 'medium', label: 'Medium', hint: '均衡' },
|
||||
{ value: 'high', label: 'High', hint: '深入' },
|
||||
{ value: 'xhigh', label: 'XHigh', hint: '超高' },
|
||||
{ value: 'max', label: 'Max', hint: '最大' },
|
||||
];
|
||||
|
||||
export const thinkingAliasSourceKindLabel = (kind: string) => {
|
||||
if (kind === 'codex-oauth') return 'Codex OAuth';
|
||||
if (kind === 'codex-api') return 'Codex API';
|
||||
if (kind === 'openai-compatible') return 'OpenAI 兼容';
|
||||
return '其他来源';
|
||||
};
|
||||
|
||||
const thinkingAliasProviderDetail = (kind: string, provider: string) => (
|
||||
provider === thinkingAliasSourceKindLabel(kind) ? '内核模型目录' : provider
|
||||
);
|
||||
|
||||
const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
|
||||
thinkingAliasProviderDetail(source.kind, source.provider)
|
||||
);
|
||||
|
||||
export function ThinkingAliasesPage() {
|
||||
const [entries, setEntries] = useState<ThinkingAliasEntry[]>([]);
|
||||
const [sources, setSources] = useState<ThinkingAliasSource[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState('');
|
||||
const [effort, setEffort] = useState('xhigh');
|
||||
const [customEffortOpen, setCustomEffortOpen] = useState(false);
|
||||
const [alias, setAlias] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
|
||||
const modelPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyAlias, setBusyAlias] = useState('');
|
||||
const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [nextEntries, nextSources] = await Promise.all([
|
||||
invoke<ThinkingAliasEntry[]>('get_thinking_aliases'),
|
||||
invoke<ThinkingAliasSource[]>('get_thinking_alias_sources'),
|
||||
]);
|
||||
setEntries(nextEntries);
|
||||
setSources(nextSources);
|
||||
setSelectedSourceId((current) => (
|
||||
nextSources.some((source) => source.id === current) ? current : ''
|
||||
));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelPickerOpen) return undefined;
|
||||
const closeOnOutsideClick = (event: PointerEvent) => {
|
||||
if (!modelPickerRef.current?.contains(event.target as Node)) {
|
||||
setModelPickerOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||
}, [modelPickerOpen]);
|
||||
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources],
|
||||
);
|
||||
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return sources;
|
||||
return sources
|
||||
.map((source, index) => {
|
||||
const model = source.model.toLowerCase();
|
||||
const displayName = (source.displayName ?? '').toLowerCase();
|
||||
const haystack = `${model} ${displayName} ${source.provider} ${thinkingAliasSourceKindLabel(source.kind)}`
|
||||
.toLowerCase();
|
||||
let score = 5;
|
||||
if (model === query) score = 0;
|
||||
else if (displayName === query) score = 1;
|
||||
else if (model.startsWith(query)) score = 2;
|
||||
else if (displayName.startsWith(query)) score = 3;
|
||||
else if (haystack.includes(query)) score = 4;
|
||||
return { source, index, score };
|
||||
})
|
||||
.filter((item) => item.score < 5)
|
||||
.sort((left, right) => left.score - right.score || left.index - right.index)
|
||||
.map((item) => item.source);
|
||||
}, [sources, search]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSourceIndex(0);
|
||||
}, [search, sources]);
|
||||
|
||||
const chooseSource = (source: ThinkingAliasSource) => {
|
||||
setSelectedSourceId(source.id);
|
||||
setModelPickerOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const chooseEffort = (nextEffort: string) => {
|
||||
setEffort(nextEffort);
|
||||
};
|
||||
|
||||
const handleModelSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setModelPickerOpen(false);
|
||||
setSearch('');
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!modelPickerOpen) {
|
||||
setModelPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!filteredSources.length) return;
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
setActiveSourceIndex((current) => (
|
||||
(current + direction + filteredSources.length) % filteredSources.length
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && modelPickerOpen && filteredSources[activeSourceIndex]) {
|
||||
event.preventDefault();
|
||||
chooseSource(filteredSources[activeSourceIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedEffort = effort.trim().toLowerCase();
|
||||
const customEffortSelected = Boolean(
|
||||
normalizedEffort && !effortOptions.some((option) => option.value === normalizedEffort),
|
||||
);
|
||||
|
||||
const createAlias = async () => {
|
||||
if (!selectedSource) {
|
||||
setError('请先选择原模型');
|
||||
return;
|
||||
}
|
||||
const normalizedAlias = alias.trim();
|
||||
if (!normalizedAlias) {
|
||||
setError('别名模型不能为空');
|
||||
return;
|
||||
}
|
||||
if (!normalizedEffort) {
|
||||
setError('思考强度不能为空');
|
||||
return;
|
||||
}
|
||||
setBusyAlias(normalizedAlias);
|
||||
setBusyAction('create');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const nextEntries = await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
|
||||
sourceId: selectedSource.id,
|
||||
alias: normalizedAlias,
|
||||
effort: normalizedEffort,
|
||||
});
|
||||
setEntries(nextEntries);
|
||||
setNotice(`已创建 ${normalizedAlias},思考强度固定为 ${normalizedEffort}`);
|
||||
setAlias('');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusyAlias('');
|
||||
setBusyAction('');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlias = async (entry: ThinkingAliasEntry) => {
|
||||
if (!window.confirm(
|
||||
`确定删除别名模型「${entry.alias}」吗?\n将同时移除对应的思考强度覆盖规则。`,
|
||||
)) return;
|
||||
setBusyAlias(entry.alias);
|
||||
setBusyAction('delete');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const nextEntries = await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
|
||||
alias: entry.alias,
|
||||
});
|
||||
setEntries(nextEntries);
|
||||
setNotice(`已删除 ${entry.alias}`);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusyAlias('');
|
||||
setBusyAction('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page management-page thinking-alias-page">
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Model Alias</span>
|
||||
<h1>思考别名</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="thinking-alias-feedback" aria-live="polite">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-guide">
|
||||
<BrainCircuit size={20} />
|
||||
<div className="thinking-alias-guide-copy">
|
||||
<strong>为原模型新增一个固定思考强度的自定义别名</strong>
|
||||
<p>别名名称由你填写,客户端调用它时 CPA 才会注入所选思考强度;直接调用原模型时,原有名称、配置和行为全部保持不变。</p>
|
||||
<span>原模型不受影响</span>
|
||||
</div>
|
||||
<div className="thinking-alias-flow" aria-label="思考别名工作流程">
|
||||
<span><small>原模型</small><code>deepseek-v4-pro</code></span>
|
||||
<ArrowRight size={14} />
|
||||
<div>
|
||||
<span><small>直接调用</small><code>deepseek-v4-pro · 保持不变</code></span>
|
||||
<span><small>别名调用</small><code>deepseek-v4-pro-max · 固定max强度</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-workbench">
|
||||
<section className="panel thinking-alias-builder">
|
||||
<div className="thinking-alias-panel-heading">
|
||||
<span><GitFork size={18} /></span>
|
||||
<div>
|
||||
<h2>创建思考别名</h2>
|
||||
<p>支持 Codex OAuth、Codex API 和 OpenAI 兼容接入</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field thinking-model-field">
|
||||
<label htmlFor="thinking-model-search">原模型</label>
|
||||
<div className="thinking-model-picker" ref={modelPickerRef}>
|
||||
<div className="thinking-model-search">
|
||||
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
|
||||
<input
|
||||
id="thinking-model-search"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={modelPickerOpen}
|
||||
aria-controls="thinking-model-options"
|
||||
aria-activedescendant={modelPickerOpen && filteredSources[activeSourceIndex]
|
||||
? `thinking-model-option-${activeSourceIndex}`
|
||||
: undefined}
|
||||
value={modelPickerOpen ? search : selectedSource?.model ?? ''}
|
||||
onFocus={(event) => {
|
||||
setSearch(selectedSource?.model ?? '');
|
||||
setModelPickerOpen(true);
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setModelPickerOpen(true);
|
||||
}}
|
||||
onKeyDown={handleModelSearchKeyDown}
|
||||
placeholder={loading ? '正在读取模型' : '搜索并选择原模型'}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
/>
|
||||
{!modelPickerOpen && selectedSource ? (
|
||||
<span className="thinking-source-kind">
|
||||
{thinkingAliasSourceKindLabel(selectedSource.kind)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{modelPickerOpen ? (
|
||||
<div
|
||||
className="thinking-model-list"
|
||||
id="thinking-model-options"
|
||||
role="listbox"
|
||||
aria-label="可用原模型"
|
||||
>
|
||||
{filteredSources.length === 0 ? (
|
||||
<div className="thinking-model-empty">
|
||||
{sources.length ? '没有匹配的模型或来源' : '当前没有可选模型'}
|
||||
</div>
|
||||
) : filteredSources.map((source, index) => {
|
||||
const selected = source.id === selectedSourceId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
id={`thinking-model-option-${index}`}
|
||||
className={`${selected ? 'selected ' : ''}${index === activeSourceIndex ? 'active' : ''}`.trim()}
|
||||
key={source.id}
|
||||
onMouseEnter={() => setActiveSourceIndex(index)}
|
||||
onClick={() => chooseSource(source)}
|
||||
disabled={Boolean(busyAlias)}
|
||||
>
|
||||
<span className="thinking-model-option-copy">
|
||||
<span>
|
||||
<strong title={source.model}>{source.model}</strong>
|
||||
{source.displayName && source.displayName !== source.model
|
||||
? <small>{source.displayName}</small>
|
||||
: null}
|
||||
</span>
|
||||
<span className="thinking-model-source">
|
||||
<em>{thinkingAliasSourceKindLabel(source.kind)}</em>
|
||||
<small title={thinkingAliasSourceDetail(source)}>
|
||||
{thinkingAliasSourceDetail(source)}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
{selected ? <Check size={15} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedSource ? (
|
||||
<div className="thinking-model-selection">
|
||||
<span>{thinkingAliasSourceKindLabel(selectedSource.kind)}</span>
|
||||
<strong title={thinkingAliasSourceDetail(selectedSource)}>
|
||||
来源:{thinkingAliasSourceDetail(selectedSource)}
|
||||
</strong>
|
||||
</div>
|
||||
) : (
|
||||
<small className="thinking-model-hint">同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field">
|
||||
<div className="thinking-field-heading">
|
||||
<strong>思考强度</strong>
|
||||
<span>选择常用等级,或输入上游支持的自定义值</span>
|
||||
</div>
|
||||
<div className="thinking-effort-options">
|
||||
{effortOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
className={effort.trim().toLowerCase() === option.value ? 'active' : ''}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
chooseEffort(option.value);
|
||||
setCustomEffortOpen(false);
|
||||
}}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={option.hint}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={customEffortSelected ? 'active custom' : 'custom'}
|
||||
onClick={() => {
|
||||
if (!customEffortSelected) chooseEffort('');
|
||||
setCustomEffortOpen(true);
|
||||
}}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={customEffortSelected
|
||||
? `自定义等级:${normalizedEffort}`
|
||||
: '输入模型提供商支持的其他思考等级'}
|
||||
>
|
||||
{customEffortSelected ? normalizedEffort : '自定义'}
|
||||
</button>
|
||||
</div>
|
||||
{customEffortOpen ? <div className="thinking-effort-custom">
|
||||
<input
|
||||
id="thinking-effort-custom"
|
||||
value={customEffortSelected ? effort : ''}
|
||||
onChange={(event) => chooseEffort(event.currentTarget.value)}
|
||||
placeholder="例如 minimal、auto、ultra 或厂商等级"
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
disabled={Boolean(busyAlias)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => setCustomEffortOpen(false)}
|
||||
title="收起自定义输入"
|
||||
aria-label="收起自定义输入"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<small>仅填写目标模型实际支持的等级名称</small>
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field">
|
||||
<div className="thinking-field-heading">
|
||||
<strong>别名模型名称</strong>
|
||||
<span>由你填写,仅新增入口,不会改名原模型</span>
|
||||
</div>
|
||||
<input
|
||||
id="thinking-alias-name"
|
||||
className="thinking-alias-input"
|
||||
value={alias}
|
||||
onChange={(event) => setAlias(event.currentTarget.value)}
|
||||
placeholder={selectedSource
|
||||
? `例如 ${selectedSource.model}-${normalizedEffort || 'high'}`
|
||||
: '先选择原模型,再填写客户端使用的别名'}
|
||||
disabled={Boolean(busyAlias)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-preview">
|
||||
<BrainCircuit size={18} />
|
||||
<div>
|
||||
<span>{selectedSource?.model || '未选择模型'} <ArrowRight size={13} /> {alias || '请输入别名模型名称'}</span>
|
||||
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || '未填写'}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button thinking-alias-create"
|
||||
onClick={() => void createAlias()}
|
||||
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
|
||||
>
|
||||
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
|
||||
{busyAction === 'create' ? '创建中' : '创建别名'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="panel thinking-alias-list-panel">
|
||||
<div className="thinking-alias-list-heading">
|
||||
<div>
|
||||
<h2>已创建别名</h2>
|
||||
<span>每个别名都保留原模型、来源和固定思考强度</span>
|
||||
</div>
|
||||
<strong>{entries.length}</strong>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-list">
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取配置中</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="management-empty">
|
||||
<GitFork size={25} />
|
||||
<strong>尚未创建思考别名</strong>
|
||||
<span>从左侧选择模型和强度即可创建</span>
|
||||
</div>
|
||||
) : entries.map((entry) => (
|
||||
<article className="thinking-alias-row" key={`${entry.kind}:${entry.provider}:${entry.alias}`}>
|
||||
<div className="thinking-alias-route">
|
||||
<div className="thinking-alias-route-source">
|
||||
<span title={entry.sourceModel}>{entry.sourceModel}</span>
|
||||
<small>
|
||||
<em>{thinkingAliasSourceKindLabel(entry.kind)}</em>
|
||||
<span title={thinkingAliasProviderDetail(entry.kind, entry.provider)}>
|
||||
{thinkingAliasProviderDetail(entry.kind, entry.provider)}
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
<ArrowRight size={14} />
|
||||
<strong title={entry.alias}>{entry.alias}</strong>
|
||||
</div>
|
||||
<span className={`thinking-effort-badge ${entry.effort ? '' : 'missing'}`}>
|
||||
{entry.effort ?? '未绑定强度'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => void deleteAlias(entry)}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={`删除 ${entry.alias}`}
|
||||
>
|
||||
{busyAction === 'delete' && busyAlias === entry.alias
|
||||
? <LoaderCircle size={15} className="spin" />
|
||||
: <Trash2 size={15} />}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ModelOption } from './modelService';
|
||||
|
||||
const normalized = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
const matchScore = (model: ModelOption, query: string) => {
|
||||
const name = normalized(model.name);
|
||||
const alias = normalized(model.alias ?? '');
|
||||
if (!query) return 10;
|
||||
if (name === query) return 0;
|
||||
if (alias === query) return 1;
|
||||
if (name.startsWith(query)) return 2;
|
||||
if (alias.startsWith(query)) return 3;
|
||||
if (name.includes(query)) return 4;
|
||||
if (alias.includes(query)) return 5;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
export function filterAgentModels(models: ModelOption[], search: string): ModelOption[] {
|
||||
const query = normalized(search);
|
||||
return models
|
||||
.map((model, index) => ({ model, index, score: matchScore(model, query) }))
|
||||
.filter((item) => Number.isFinite(item.score))
|
||||
.sort((left, right) => (
|
||||
left.score - right.score
|
||||
|| left.model.name.localeCompare(right.model.name, undefined, { sensitivity: 'base' })
|
||||
|| left.index - right.index
|
||||
))
|
||||
.map((item) => item.model);
|
||||
}
|
||||
|
||||
export function hasExactAgentModel(models: ModelOption[], value: string): boolean {
|
||||
const query = normalized(value);
|
||||
if (!query) return false;
|
||||
return models.some((model) => (
|
||||
normalized(model.name) === query || normalized(model.alias ?? '') === query
|
||||
));
|
||||
}
|
||||
|
||||
export function agentModelAlias(models: ModelOption[], value: string): string {
|
||||
const query = normalized(value);
|
||||
return models.find((model) => normalized(model.name) === query)?.alias ?? '';
|
||||
}
|
||||
|
||||
export function findAgentModel(models: ModelOption[], value: string): ModelOption | null {
|
||||
const query = normalized(value);
|
||||
if (!query) return null;
|
||||
return models.find((model) => normalized(model.name) === query) ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(models: ModelOption[], previous: string): string {
|
||||
return findAgentModel(models, previous)?.name ?? models[0]?.name.trim() ?? '';
|
||||
}
|
||||
+1509
-24
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
agentModelAlias,
|
||||
filterAgentModels,
|
||||
findAgentModel,
|
||||
hasExactAgentModel,
|
||||
resolveAgentModelSelection,
|
||||
} from '../src/services/agentModelPicker';
|
||||
|
||||
const models = [
|
||||
{ name: 'claude-sonnet-4-5', alias: 'Sonnet' },
|
||||
{ name: 'gpt-5.2-codex', alias: 'Codex' },
|
||||
{ name: 'gpt-5.2' },
|
||||
{ name: 'deepseek-chat', alias: 'DeepSeek V3' },
|
||||
];
|
||||
|
||||
describe('智能体模型选择器', () => {
|
||||
test('按名称和别名搜索且忽略大小写', () => {
|
||||
expect(filterAgentModels(models, 'SONNET').map((model) => model.name))
|
||||
.toEqual(['claude-sonnet-4-5']);
|
||||
expect(filterAgentModels(models, 'deepseek').map((model) => model.name))
|
||||
.toEqual(['deepseek-chat']);
|
||||
});
|
||||
|
||||
test('精确匹配和前缀匹配排在包含匹配之前', () => {
|
||||
expect(filterAgentModels(models, 'gpt-5.2').map((model) => model.name))
|
||||
.toEqual(['gpt-5.2', 'gpt-5.2-codex']);
|
||||
});
|
||||
|
||||
test('识别名称或别名的精确匹配', () => {
|
||||
expect(hasExactAgentModel(models, 'Codex')).toBeTrue();
|
||||
expect(hasExactAgentModel(models, 'gpt-5.2-codex')).toBeTrue();
|
||||
expect(hasExactAgentModel(models, 'custom-model')).toBeFalse();
|
||||
});
|
||||
|
||||
test('根据当前模型显示别名', () => {
|
||||
expect(agentModelAlias(models, 'CLAUDE-SONNET-4-5')).toBe('Sonnet');
|
||||
expect(agentModelAlias(models, 'gpt-5.2')).toBe('');
|
||||
});
|
||||
|
||||
test('没有历史选择时默认第一项,旧选择失效时也回退第一项', () => {
|
||||
expect(resolveAgentModelSelection(models, '')).toBe('claude-sonnet-4-5');
|
||||
expect(resolveAgentModelSelection(models, 'removed-model')).toBe('claude-sonnet-4-5');
|
||||
expect(resolveAgentModelSelection(models, 'GPT-5.2-CODEX')).toBe('gpt-5.2-codex');
|
||||
expect(resolveAgentModelSelection([], 'gpt-5.2')).toBe('');
|
||||
});
|
||||
|
||||
test('配置前只能解析模型列表中真实存在的模型', () => {
|
||||
expect(findAgentModel(models, 'codex')?.name).toBeUndefined();
|
||||
expect(findAgentModel(models, 'gpt-5.2-codex')?.name).toBe('gpt-5.2-codex');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
allModelSelectionForDiscovery,
|
||||
applyProviderPreset,
|
||||
buildProviderRecord,
|
||||
createProviderDraft,
|
||||
@@ -67,7 +68,7 @@ describe('API 接入配置合并', () => {
|
||||
apiKey: 'openai-key',
|
||||
name: 'custom-openai',
|
||||
baseUrl: 'https://api.example.com',
|
||||
thinkingLevels: ['low', 'high'],
|
||||
thinkingLevels: ['fast', 'ultra'],
|
||||
models: [
|
||||
{ name: 'reasoning-a' },
|
||||
{ name: 'reasoning-b', thinking: { effort: 'high' } },
|
||||
@@ -76,10 +77,10 @@ describe('API 接入配置合并', () => {
|
||||
const result = buildProviderRecord('openai-compatibility', draft);
|
||||
|
||||
expect(result.models).toEqual([
|
||||
{ name: 'reasoning-a', thinking: { levels: ['low', 'high'] } },
|
||||
{ name: 'reasoning-a', thinking: { levels: ['fast', 'ultra'] } },
|
||||
{
|
||||
name: 'reasoning-b',
|
||||
thinking: { effort: 'high', levels: ['low', 'high'] },
|
||||
thinking: { effort: 'high', levels: ['fast', 'ultra'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -255,4 +256,18 @@ describe('API 接入配置合并', () => {
|
||||
|
||||
expect(Array.from(selected)).toEqual(['model-a', 'model-b']);
|
||||
});
|
||||
|
||||
it('模型选择窗口每次打开都以接口返回的全部模型作为默认选择', () => {
|
||||
const selected = allModelSelectionForDiscovery([
|
||||
{ name: 'deepseek-chat' },
|
||||
{ name: 'deepseek-reasoner' },
|
||||
{ name: 'deepseek-new-model' },
|
||||
]);
|
||||
|
||||
expect(Array.from(selected)).toEqual([
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-new-model',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { thinkingAliasSourceKindLabel } from '../src/pages/ThinkingAliasesPage';
|
||||
|
||||
describe('思考别名', () => {
|
||||
test('区分同名模型的接入来源', () => {
|
||||
expect(thinkingAliasSourceKindLabel('codex-oauth')).toBe('Codex OAuth');
|
||||
expect(thinkingAliasSourceKindLabel('codex-api')).toBe('Codex API');
|
||||
expect(thinkingAliasSourceKindLabel('openai-compatible')).toBe('OpenAI 兼容');
|
||||
expect(thinkingAliasSourceKindLabel('custom')).toBe('其他来源');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user