feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651)
This commit is contained in:
@@ -100,6 +100,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
|||||||
### 💬 インテリジェントチャットインターフェース
|
### 💬 インテリジェントチャットインターフェース
|
||||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||||
|
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||||
|
|
||||||
### 📡 マルチチャネル管理
|
### 📡 マルチチャネル管理
|
||||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ Complete the entire setup—from installation to your first AI interaction—thr
|
|||||||
### 💬 Intelligent Chat Interface
|
### 💬 Intelligent Chat Interface
|
||||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
|
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||||
|
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||||
|
|
||||||
### 📡 Multi-Channel Management
|
### 📡 Multi-Channel Management
|
||||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
|||||||
### 💬 智能聊天界面
|
### 💬 智能聊天界面
|
||||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||||
|
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||||
|
|
||||||
### 📡 多频道管理
|
### 📡 多频道管理
|
||||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import {
|
|||||||
listAgentsSnapshot,
|
listAgentsSnapshot,
|
||||||
removeAgentWorkspaceDirectory,
|
removeAgentWorkspaceDirectory,
|
||||||
resolveAccountIdForAgent,
|
resolveAccountIdForAgent,
|
||||||
|
updateAgentModel,
|
||||||
updateAgentName,
|
updateAgentName,
|
||||||
} from '../../utils/agent-config';
|
} from '../../utils/agent-config';
|
||||||
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
||||||
import { syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
|
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
|
|
||||||
@@ -151,6 +152,26 @@ export async function handleAgentRoutes(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parts.length === 2 && parts[1] === 'model') {
|
||||||
|
try {
|
||||||
|
const body = await parseJsonBody<{ modelRef?: string | null }>(req);
|
||||||
|
const agentId = decodeURIComponent(parts[0]);
|
||||||
|
const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
|
||||||
|
try {
|
||||||
|
await syncAllProviderAuthToRuntime();
|
||||||
|
// Ensure this agent's runtime model registry reflects the new model override.
|
||||||
|
await syncAgentModelOverrideToRuntime(agentId);
|
||||||
|
} catch (syncError) {
|
||||||
|
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
|
||||||
|
}
|
||||||
|
scheduleGatewayReload(ctx, 'update-agent-model');
|
||||||
|
sendJson(res, 200, { success: true, ...snapshot });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { success: false, error: String(error) });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length === 3 && parts[1] === 'channels') {
|
if (parts.length === 3 && parts[1] === 'channels') {
|
||||||
try {
|
try {
|
||||||
const agentId = decodeURIComponent(parts[0]);
|
const agentId = decodeURIComponent(parts[0]);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
setOpenClawDefaultModelWithOverride,
|
setOpenClawDefaultModelWithOverride,
|
||||||
syncProviderConfigToOpenClaw,
|
syncProviderConfigToOpenClaw,
|
||||||
updateAgentModelProvider,
|
updateAgentModelProvider,
|
||||||
|
updateSingleAgentModelProvider,
|
||||||
} from '../../utils/openclaw-auth';
|
} from '../../utils/openclaw-auth';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { listAgentsSnapshot } from '../../utils/agent-config';
|
||||||
|
|
||||||
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
||||||
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
||||||
@@ -336,6 +338,116 @@ async function syncProviderToRuntime(
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
|
||||||
|
const trimmed = modelRef.trim();
|
||||||
|
const separatorIndex = trimmed.indexOf('/');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerKey: trimmed.slice(0, separatorIndex),
|
||||||
|
modelId: trimmed.slice(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
|
||||||
|
const configs = await getAllProviders();
|
||||||
|
const runtimeMap = new Map<string, ProviderConfig>();
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const runtimeKey = await resolveRuntimeProviderKey(config);
|
||||||
|
runtimeMap.set(runtimeKey, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runtimeMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAgentModelProviderEntry(
|
||||||
|
config: ProviderConfig,
|
||||||
|
modelId: string,
|
||||||
|
): Promise<{
|
||||||
|
baseUrl?: string;
|
||||||
|
api?: string;
|
||||||
|
models?: Array<{ id: string; name: string }>;
|
||||||
|
apiKey?: string;
|
||||||
|
authHeader?: boolean;
|
||||||
|
} | null> {
|
||||||
|
const meta = getProviderConfig(config.type);
|
||||||
|
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
|
||||||
|
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
|
||||||
|
if (!api || !baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiKey: string | undefined;
|
||||||
|
let authHeader: boolean | undefined;
|
||||||
|
|
||||||
|
if (config.type === 'custom') {
|
||||||
|
apiKey = (await getApiKey(config.id)) || undefined;
|
||||||
|
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
||||||
|
const accountApiKey = await getApiKey(config.id);
|
||||||
|
if (accountApiKey) {
|
||||||
|
apiKey = accountApiKey;
|
||||||
|
} else {
|
||||||
|
authHeader = true;
|
||||||
|
apiKey = 'minimax-oauth';
|
||||||
|
}
|
||||||
|
} else if (config.type === 'qwen-portal') {
|
||||||
|
const accountApiKey = await getApiKey(config.id);
|
||||||
|
if (accountApiKey) {
|
||||||
|
apiKey = accountApiKey;
|
||||||
|
} else {
|
||||||
|
apiKey = 'qwen-oauth';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
api,
|
||||||
|
models: [{ id: modelId, name: modelId }],
|
||||||
|
apiKey,
|
||||||
|
authHeader,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAgentModelsToRuntime(agentIds?: Set<string>): Promise<void> {
|
||||||
|
const snapshot = await listAgentsSnapshot();
|
||||||
|
const runtimeProviderConfigs = await buildRuntimeProviderConfigMap();
|
||||||
|
|
||||||
|
const targets = snapshot.agents.filter((agent) => {
|
||||||
|
if (!agent.modelRef) return false;
|
||||||
|
if (!agentIds) return true;
|
||||||
|
return agentIds.has(agent.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const agent of targets) {
|
||||||
|
const parsed = parseModelRef(agent.modelRef || '');
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = runtimeProviderConfigs.get(parsed.providerKey);
|
||||||
|
if (!providerConfig) {
|
||||||
|
logger.warn(
|
||||||
|
`[provider-runtime] No provider account mapped to runtime key "${parsed.providerKey}" for agent "${agent.id}"`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await buildAgentModelProviderEntry(providerConfig, parsed.modelId);
|
||||||
|
if (!entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSingleAgentModelProvider(agent.id, parsed.providerKey, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<void> {
|
||||||
|
await syncAgentModelsToRuntime(new Set([agentId]));
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncSavedProviderToRuntime(
|
export async function syncSavedProviderToRuntime(
|
||||||
config: ProviderConfig,
|
config: ProviderConfig,
|
||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
@@ -346,6 +458,12 @@ export async function syncSavedProviderToRuntime(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAgentModelsToRuntime();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
|
||||||
|
}
|
||||||
|
|
||||||
scheduleGatewayRefresh(
|
scheduleGatewayRefresh(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
||||||
@@ -388,6 +506,12 @@ export async function syncUpdatedProviderToRuntime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAgentModelsToRuntime();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
|
||||||
|
}
|
||||||
|
|
||||||
scheduleGatewayRefresh(
|
scheduleGatewayRefresh(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
||||||
@@ -496,6 +620,11 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
|
|
||||||
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
||||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||||
|
try {
|
||||||
|
await syncAgentModelsToRuntime();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
|
||||||
|
}
|
||||||
scheduleGatewayRefresh(
|
scheduleGatewayRefresh(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||||
@@ -557,6 +686,12 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAgentModelsToRuntime();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
|
||||||
|
}
|
||||||
|
|
||||||
scheduleGatewayRefresh(
|
scheduleGatewayRefresh(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export interface AgentSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
modelDisplay: string;
|
modelDisplay: string;
|
||||||
|
modelRef: string | null;
|
||||||
|
overrideModelRef: string | null;
|
||||||
inheritedModel: boolean;
|
inheritedModel: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
@@ -91,29 +93,38 @@ export interface AgentSummary {
|
|||||||
export interface AgentsSnapshot {
|
export interface AgentsSnapshot {
|
||||||
agents: AgentSummary[];
|
agents: AgentSummary[];
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
|
defaultModelRef: string | null;
|
||||||
configuredChannelTypes: string[];
|
configuredChannelTypes: string[];
|
||||||
channelOwners: Record<string, string>;
|
channelOwners: Record<string, string>;
|
||||||
channelAccountOwners: Record<string, string>;
|
channelAccountOwners: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModelLabel(model: unknown): string | null {
|
function resolveModelRef(model: unknown): string | null {
|
||||||
if (typeof model === 'string' && model.trim()) {
|
if (typeof model === 'string' && model.trim()) {
|
||||||
const trimmed = model.trim();
|
return model.trim();
|
||||||
const parts = trimmed.split('/');
|
|
||||||
return parts[parts.length - 1] || trimmed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model && typeof model === 'object') {
|
if (model && typeof model === 'object') {
|
||||||
const primary = (model as AgentModelConfig).primary;
|
const primary = (model as AgentModelConfig).primary;
|
||||||
if (typeof primary === 'string' && primary.trim()) {
|
if (typeof primary === 'string' && primary.trim()) {
|
||||||
const parts = primary.trim().split('/');
|
return primary.trim();
|
||||||
return parts[parts.length - 1] || primary.trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatModelLabel(model: unknown): string | null {
|
||||||
|
const modelRef = resolveModelRef(model);
|
||||||
|
if (modelRef) {
|
||||||
|
const trimmed = modelRef;
|
||||||
|
const parts = trimmed.split('/');
|
||||||
|
return parts[parts.length - 1] || trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAgentName(name: string): string {
|
function normalizeAgentName(name: string): string {
|
||||||
return name.trim() || 'Agent';
|
return name.trim() || 'Agent';
|
||||||
}
|
}
|
||||||
@@ -487,10 +498,13 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
channelOwners[channelType] = primaryOwner;
|
channelOwners[channelType] = primaryOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
const defaultModelConfig = (config.agents as AgentsConfig | undefined)?.defaults?.model;
|
||||||
|
const defaultModelLabel = formatModelLabel(defaultModelConfig);
|
||||||
|
const defaultModelRef = resolveModelRef(defaultModelConfig);
|
||||||
const agents: AgentSummary[] = entries.map((entry) => {
|
const agents: AgentSummary[] = entries.map((entry) => {
|
||||||
|
const explicitModelRef = resolveModelRef(entry.model);
|
||||||
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
||||||
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
const inheritedModel = !explicitModelRef && Boolean(defaultModelLabel);
|
||||||
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
||||||
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
||||||
return {
|
return {
|
||||||
@@ -498,6 +512,8 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
||||||
isDefault: entry.id === defaultAgentId,
|
isDefault: entry.id === defaultAgentId,
|
||||||
modelDisplay: modelLabel,
|
modelDisplay: modelLabel,
|
||||||
|
modelRef: explicitModelRef || defaultModelRef || null,
|
||||||
|
overrideModelRef: explicitModelRef,
|
||||||
inheritedModel,
|
inheritedModel,
|
||||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||||
@@ -511,6 +527,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
defaultAgentId,
|
defaultAgentId,
|
||||||
|
defaultModelRef,
|
||||||
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
|
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
|
||||||
channelOwners,
|
channelOwners,
|
||||||
channelAccountOwners,
|
channelAccountOwners,
|
||||||
@@ -598,6 +615,44 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidModelRef(modelRef: string): boolean {
|
||||||
|
const firstSlash = modelRef.indexOf('/');
|
||||||
|
return firstSlash > 0 && firstSlash < modelRef.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
|
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||||
|
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Agent "${agentId}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||||
|
const nextEntry: AgentListEntry = { ...entries[index] };
|
||||||
|
|
||||||
|
if (!normalizedModelRef) {
|
||||||
|
delete nextEntry.model;
|
||||||
|
} else {
|
||||||
|
if (!isValidModelRef(normalizedModelRef)) {
|
||||||
|
throw new Error('modelRef must be in "provider/model" format');
|
||||||
|
}
|
||||||
|
nextEntry.model = { primary: normalizedModelRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[index] = nextEntry;
|
||||||
|
config.agents = {
|
||||||
|
...agentsConfig,
|
||||||
|
list: entries,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeOpenClawConfig(config);
|
||||||
|
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||||
|
return buildSnapshotFromConfig(config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
||||||
return withConfigLock(async () => {
|
return withConfigLock(async () => {
|
||||||
if (agentId === MAIN_AGENT_ID) {
|
if (agentId === MAIN_AGENT_ID) {
|
||||||
|
|||||||
@@ -918,18 +918,20 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Update a provider entry in every discovered agent's models.json.
|
* Update a provider entry in every discovered agent's models.json.
|
||||||
*/
|
*/
|
||||||
export async function updateAgentModelProvider(
|
type AgentModelProviderEntry = {
|
||||||
|
baseUrl?: string;
|
||||||
|
api?: string;
|
||||||
|
models?: Array<{ id: string; name: string }>;
|
||||||
|
apiKey?: string;
|
||||||
|
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
||||||
|
authHeader?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateModelsJsonProviderEntriesForAgents(
|
||||||
|
agentIds: string[],
|
||||||
providerType: string,
|
providerType: string,
|
||||||
entry: {
|
entry: AgentModelProviderEntry,
|
||||||
baseUrl?: string;
|
|
||||||
api?: string;
|
|
||||||
models?: Array<{ id: string; name: string }>;
|
|
||||||
apiKey?: string;
|
|
||||||
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
|
||||||
authHeader?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const agentIds = await discoverAgentIds();
|
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||||
let data: Record<string, unknown> = {};
|
let data: Record<string, unknown> = {};
|
||||||
@@ -975,6 +977,26 @@ export async function updateAgentModelProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAgentModelProvider(
|
||||||
|
providerType: string,
|
||||||
|
entry: AgentModelProviderEntry,
|
||||||
|
): Promise<void> {
|
||||||
|
const agentIds = await discoverAgentIds();
|
||||||
|
await updateModelsJsonProviderEntriesForAgents(agentIds, providerType, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSingleAgentModelProvider(
|
||||||
|
agentId: string,
|
||||||
|
providerType: string,
|
||||||
|
entry: AgentModelProviderEntry,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedAgentId = agentId.trim();
|
||||||
|
if (!normalizedAgentId) {
|
||||||
|
throw new Error('agentId is required');
|
||||||
|
}
|
||||||
|
await updateModelsJsonProviderEntriesForAgents([normalizedAgentId], providerType, entry);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize ~/.openclaw/openclaw.json before Gateway start.
|
* Sanitize ~/.openclaw/openclaw.json before Gateway start.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
"nameLabel": "Agent Name",
|
"nameLabel": "Agent Name",
|
||||||
"agentIdLabel": "Agent ID",
|
"agentIdLabel": "Agent ID",
|
||||||
"modelLabel": "Model",
|
"modelLabel": "Model",
|
||||||
|
"modelOverrideLabel": "Model Override",
|
||||||
|
"modelProviderLabel": "Provider",
|
||||||
|
"modelProviderPlaceholder": "Select a provider",
|
||||||
|
"modelProviderEmpty": "No provider accounts configured. Add one in Settings → AI Providers.",
|
||||||
|
"modelIdLabel": "Model ID",
|
||||||
|
"modelIdPlaceholder": "model-id",
|
||||||
|
"modelPreview": "Preview",
|
||||||
|
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
|
||||||
|
"modelOverrideDescription": "Select provider and model ID for this agent.",
|
||||||
|
"saveModelOverride": "Save model",
|
||||||
|
"useDefaultModel": "Use default model",
|
||||||
"channelsTitle": "Channels",
|
"channelsTitle": "Channels",
|
||||||
"channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
|
"channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
|
||||||
"mainAccount": "Main account",
|
"mainAccount": "Main account",
|
||||||
@@ -48,6 +59,13 @@
|
|||||||
"agentDeleteFailed": "Failed to delete agent: {{error}}",
|
"agentDeleteFailed": "Failed to delete agent: {{error}}",
|
||||||
"agentUpdated": "Agent updated",
|
"agentUpdated": "Agent updated",
|
||||||
"agentUpdateFailed": "Failed to update agent: {{error}}",
|
"agentUpdateFailed": "Failed to update agent: {{error}}",
|
||||||
|
"agentModelInvalid": "Model must be in provider/model format",
|
||||||
|
"agentModelProviderRequired": "Select a provider first",
|
||||||
|
"agentModelIdRequired": "Model ID is required",
|
||||||
|
"agentModelUpdated": "Agent model updated",
|
||||||
|
"agentModelUpdateFailed": "Failed to update agent model: {{error}}",
|
||||||
|
"agentModelReset": "Agent model reset to default",
|
||||||
|
"agentModelResetFailed": "Failed to reset agent model: {{error}}",
|
||||||
"channelAssigned": "{{channel}} assigned to agent",
|
"channelAssigned": "{{channel}} assigned to agent",
|
||||||
"channelAssignFailed": "Failed to assign channel: {{error}}",
|
"channelAssignFailed": "Failed to assign channel: {{error}}",
|
||||||
"channelRemoved": "{{channel}} removed",
|
"channelRemoved": "{{channel}} removed",
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
"nameLabel": "Agent 名",
|
"nameLabel": "Agent 名",
|
||||||
"agentIdLabel": "Agent ID",
|
"agentIdLabel": "Agent ID",
|
||||||
"modelLabel": "Model",
|
"modelLabel": "Model",
|
||||||
|
"modelOverrideLabel": "モデル上書き",
|
||||||
|
"modelProviderLabel": "Provider",
|
||||||
|
"modelProviderPlaceholder": "Provider を選択",
|
||||||
|
"modelProviderEmpty": "Provider アカウントが未設定です。設定 → AI Providers で追加してください。",
|
||||||
|
"modelIdLabel": "モデル ID",
|
||||||
|
"modelIdPlaceholder": "model-id",
|
||||||
|
"modelPreview": "プレビュー",
|
||||||
|
"modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.4)",
|
||||||
|
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
|
||||||
|
"saveModelOverride": "モデルを保存",
|
||||||
|
"useDefaultModel": "デフォルトモデルを使用",
|
||||||
"channelsTitle": "Channels",
|
"channelsTitle": "Channels",
|
||||||
"channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。",
|
"channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。",
|
||||||
"mainAccount": "メインアカウント",
|
"mainAccount": "メインアカウント",
|
||||||
@@ -48,6 +59,13 @@
|
|||||||
"agentDeleteFailed": "Agent の削除に失敗しました: {{error}}",
|
"agentDeleteFailed": "Agent の削除に失敗しました: {{error}}",
|
||||||
"agentUpdated": "Agent を更新しました",
|
"agentUpdated": "Agent を更新しました",
|
||||||
"agentUpdateFailed": "Agent の更新に失敗しました: {{error}}",
|
"agentUpdateFailed": "Agent の更新に失敗しました: {{error}}",
|
||||||
|
"agentModelInvalid": "モデル形式は provider/model で指定してください",
|
||||||
|
"agentModelProviderRequired": "先に Provider を選択してください",
|
||||||
|
"agentModelIdRequired": "モデル ID は必須です",
|
||||||
|
"agentModelUpdated": "Agent のモデルを更新しました",
|
||||||
|
"agentModelUpdateFailed": "Agent モデルの更新に失敗しました: {{error}}",
|
||||||
|
"agentModelReset": "Agent モデルをデフォルトに戻しました",
|
||||||
|
"agentModelResetFailed": "Agent モデルのリセットに失敗しました: {{error}}",
|
||||||
"channelAssigned": "{{channel}} を Agent に割り当てました",
|
"channelAssigned": "{{channel}} を Agent に割り当てました",
|
||||||
"channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}",
|
"channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}",
|
||||||
"channelRemoved": "{{channel}} を削除しました",
|
"channelRemoved": "{{channel}} を削除しました",
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
"nameLabel": "Agent 名称",
|
"nameLabel": "Agent 名称",
|
||||||
"agentIdLabel": "Agent ID",
|
"agentIdLabel": "Agent ID",
|
||||||
"modelLabel": "Model",
|
"modelLabel": "Model",
|
||||||
|
"modelOverrideLabel": "模型覆盖",
|
||||||
|
"modelProviderLabel": "Provider",
|
||||||
|
"modelProviderPlaceholder": "选择 Provider",
|
||||||
|
"modelProviderEmpty": "尚未配置 Provider 账号。请先前往 设置 → AI Providers 添加。",
|
||||||
|
"modelIdLabel": "模型 ID",
|
||||||
|
"modelIdPlaceholder": "model-id",
|
||||||
|
"modelPreview": "预览",
|
||||||
|
"modelOverridePlaceholder": "provider/model(例如:openrouter/openai/gpt-5.4)",
|
||||||
|
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
|
||||||
|
"saveModelOverride": "保存模型",
|
||||||
|
"useDefaultModel": "使用默认模型",
|
||||||
"channelsTitle": "频道",
|
"channelsTitle": "频道",
|
||||||
"channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。",
|
"channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。",
|
||||||
"mainAccount": "主账号",
|
"mainAccount": "主账号",
|
||||||
@@ -48,6 +59,13 @@
|
|||||||
"agentDeleteFailed": "删除 Agent 失败:{{error}}",
|
"agentDeleteFailed": "删除 Agent 失败:{{error}}",
|
||||||
"agentUpdated": "Agent 已更新",
|
"agentUpdated": "Agent 已更新",
|
||||||
"agentUpdateFailed": "更新 Agent 失败:{{error}}",
|
"agentUpdateFailed": "更新 Agent 失败:{{error}}",
|
||||||
|
"agentModelInvalid": "模型格式必须为 provider/model",
|
||||||
|
"agentModelProviderRequired": "请先选择 Provider",
|
||||||
|
"agentModelIdRequired": "模型 ID 不能为空",
|
||||||
|
"agentModelUpdated": "Agent 模型已更新",
|
||||||
|
"agentModelUpdateFailed": "更新 Agent 模型失败:{{error}}",
|
||||||
|
"agentModelReset": "Agent 模型已恢复为默认",
|
||||||
|
"agentModelResetFailed": "恢复 Agent 默认模型失败:{{error}}",
|
||||||
"channelAssigned": "{{channel}} 已分配给 Agent",
|
"channelAssigned": "{{channel}} 已分配给 Agent",
|
||||||
"channelAssignFailed": "分配频道失败:{{error}}",
|
"channelAssignFailed": "分配频道失败:{{error}}",
|
||||||
"channelRemoved": "{{channel}} 已移除",
|
"channelRemoved": "{{channel}} 已移除",
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { useAgentsStore } from '@/stores/agents';
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useProviderStore } from '@/stores/providers';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { subscribeHostEvent } from '@/lib/host-events';
|
import { subscribeHostEvent } from '@/lib/host-events';
|
||||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||||
import type { AgentSummary } from '@/types/agent';
|
import type { AgentSummary } from '@/types/agent';
|
||||||
|
import type { ProviderAccount, ProviderVendorInfo, ProviderWithKeyInfo } from '@/lib/providers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -43,9 +45,57 @@ interface ChannelGroupItem {
|
|||||||
accounts: ChannelAccountItem[];
|
accounts: ChannelAccountItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeProviderOption {
|
||||||
|
runtimeProviderKey: string;
|
||||||
|
accountId: string;
|
||||||
|
label: string;
|
||||||
|
modelIdPlaceholder?: string;
|
||||||
|
configuredModelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeProviderKey(account: ProviderAccount): string {
|
||||||
|
if (account.authMode === 'oauth_browser') {
|
||||||
|
if (account.vendorId === 'google') return 'google-gemini-cli';
|
||||||
|
if (account.vendorId === 'openai') return 'openai-codex';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.vendorId === 'custom' || account.vendorId === 'ollama') {
|
||||||
|
const suffix = account.id.replace(/-/g, '').slice(0, 8);
|
||||||
|
return `${account.vendorId}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.vendorId === 'minimax-portal-cn') {
|
||||||
|
return 'minimax-portal';
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitModelRef(modelRef: string | null | undefined): { providerKey: string; modelId: string } | null {
|
||||||
|
const value = (modelRef || '').trim();
|
||||||
|
if (!value) return null;
|
||||||
|
const separatorIndex = value.indexOf('/');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex >= value.length - 1) return null;
|
||||||
|
return {
|
||||||
|
providerKey: value.slice(0, separatorIndex),
|
||||||
|
modelId: value.slice(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConfiguredProviderCredentials(
|
||||||
|
account: ProviderAccount,
|
||||||
|
statusById: Map<string, ProviderWithKeyInfo>,
|
||||||
|
): boolean {
|
||||||
|
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return statusById.get(account.id)?.hasKey ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
export function Agents() {
|
export function Agents() {
|
||||||
const { t } = useTranslation('agents');
|
const { t } = useTranslation('agents');
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
|
||||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||||
const {
|
const {
|
||||||
agents,
|
agents,
|
||||||
@@ -72,8 +122,8 @@ export function Agents() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
|
||||||
}, [fetchAgents, fetchChannelAccounts]);
|
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
|
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
|
||||||
@@ -316,6 +366,7 @@ function AgentCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
|
const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
|
||||||
|
const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3';
|
||||||
const labelClasses = 'text-[14px] text-foreground/80 font-bold';
|
const labelClasses = 'text-[14px] text-foreground/80 font-bold';
|
||||||
|
|
||||||
function ChannelLogo({ type }: { type: ChannelType }) {
|
function ChannelLogo({ type }: { type: ChannelType }) {
|
||||||
@@ -438,9 +489,10 @@ function AgentSettingsModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('agents');
|
const { t } = useTranslation('agents');
|
||||||
const { updateAgent } = useAgentsStore();
|
const { updateAgent, defaultModelRef } = useAgentsStore();
|
||||||
const [name, setName] = useState(agent.name);
|
const [name, setName] = useState(agent.name);
|
||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [showModelModal, setShowModelModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(agent.name);
|
setName(agent.name);
|
||||||
@@ -530,7 +582,11 @@ function AgentSettingsModal({
|
|||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-[13px] text-foreground">{agent.id}</p>
|
<p className="font-mono text-[13px] text-foreground">{agent.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModelModal(true)}
|
||||||
|
className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4 text-left hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground/80 font-medium">
|
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground/80 font-medium">
|
||||||
{t('settingsDialog.modelLabel')}
|
{t('settingsDialog.modelLabel')}
|
||||||
</p>
|
</p>
|
||||||
@@ -538,7 +594,10 @@ function AgentSettingsModal({
|
|||||||
{agent.modelDisplay}
|
{agent.modelDisplay}
|
||||||
{agent.inheritedModel ? ` (${t('inherited')})` : ''}
|
{agent.inheritedModel ? ` (${t('inherited')})` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="font-mono text-[12px] text-foreground/70 break-all">
|
||||||
|
{agent.modelRef || defaultModelRef || '-'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -587,6 +646,233 @@ function AgentSettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{showModelModal && (
|
||||||
|
<AgentModelModal
|
||||||
|
agent={agent}
|
||||||
|
onClose={() => setShowModelModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentModelModal({
|
||||||
|
agent,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
agent: AgentSummary;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('agents');
|
||||||
|
const providerAccounts = useProviderStore((state) => state.accounts);
|
||||||
|
const providerStatuses = useProviderStore((state) => state.statuses);
|
||||||
|
const providerVendors = useProviderStore((state) => state.vendors);
|
||||||
|
const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
|
||||||
|
const { updateAgentModel, defaultModelRef } = useAgentsStore();
|
||||||
|
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
|
||||||
|
const [modelIdInput, setModelIdInput] = useState('');
|
||||||
|
const [savingModel, setSavingModel] = useState(false);
|
||||||
|
|
||||||
|
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
|
||||||
|
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
|
||||||
|
const statusById = new Map<string, ProviderWithKeyInfo>(providerStatuses.map((status) => [status.id, status]));
|
||||||
|
const entries = providerAccounts
|
||||||
|
.filter((account) => account.enabled && hasConfiguredProviderCredentials(account, statusById))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.id === providerDefaultAccountId) return -1;
|
||||||
|
if (right.id === providerDefaultAccountId) return 1;
|
||||||
|
return right.updatedAt.localeCompare(left.updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deduped = new Map<string, RuntimeProviderOption>();
|
||||||
|
for (const account of entries) {
|
||||||
|
const runtimeProviderKey = resolveRuntimeProviderKey(account);
|
||||||
|
if (!runtimeProviderKey || deduped.has(runtimeProviderKey)) continue;
|
||||||
|
const vendor = vendorMap.get(account.vendorId);
|
||||||
|
const label = `${account.label} (${vendor?.name || account.vendorId})`;
|
||||||
|
const configuredModelId = account.model
|
||||||
|
? (account.model.startsWith(`${runtimeProviderKey}/`)
|
||||||
|
? account.model.slice(runtimeProviderKey.length + 1)
|
||||||
|
: account.model)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
deduped.set(runtimeProviderKey, {
|
||||||
|
runtimeProviderKey,
|
||||||
|
accountId: account.id,
|
||||||
|
label,
|
||||||
|
modelIdPlaceholder: vendor?.modelIdPlaceholder,
|
||||||
|
configuredModelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...deduped.values()];
|
||||||
|
}, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const override = splitModelRef(agent.overrideModelRef);
|
||||||
|
if (override) {
|
||||||
|
setSelectedRuntimeProviderKey(override.providerKey);
|
||||||
|
setModelIdInput(override.modelId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effective = splitModelRef(agent.modelRef || defaultModelRef);
|
||||||
|
if (effective) {
|
||||||
|
setSelectedRuntimeProviderKey(effective.providerKey);
|
||||||
|
setModelIdInput(effective.modelId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || '');
|
||||||
|
setModelIdInput('');
|
||||||
|
}, [agent.modelRef, agent.overrideModelRef, defaultModelRef, runtimeProviderOptions]);
|
||||||
|
|
||||||
|
const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null;
|
||||||
|
const trimmedModelId = modelIdInput.trim();
|
||||||
|
const nextModelRef = selectedRuntimeProviderKey && trimmedModelId
|
||||||
|
? `${selectedRuntimeProviderKey}/${trimmedModelId}`
|
||||||
|
: '';
|
||||||
|
const normalizedDefaultModelRef = (defaultModelRef || '').trim();
|
||||||
|
const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef;
|
||||||
|
const currentOverrideModelRef = (agent.overrideModelRef || '').trim();
|
||||||
|
const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef
|
||||||
|
? nextModelRef
|
||||||
|
: null;
|
||||||
|
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
|
||||||
|
|
||||||
|
const handleSaveModel = async () => {
|
||||||
|
if (!selectedRuntimeProviderKey) {
|
||||||
|
toast.error(t('toast.agentModelProviderRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedModelId) {
|
||||||
|
toast.error(t('toast.agentModelIdRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!modelChanged) return;
|
||||||
|
if (!nextModelRef.includes('/')) {
|
||||||
|
toast.error(t('toast.agentModelInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingModel(true);
|
||||||
|
try {
|
||||||
|
await updateAgentModel(agent.id, desiredOverrideModelRef);
|
||||||
|
toast.success(desiredOverrideModelRef ? t('toast.agentModelUpdated') : t('toast.agentModelReset'));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('toast.agentModelUpdateFailed', { error: String(error) }));
|
||||||
|
} finally {
|
||||||
|
setSavingModel(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseDefaultModel = () => {
|
||||||
|
const parsedDefault = splitModelRef(normalizedDefaultModelRef);
|
||||||
|
if (!parsedDefault) {
|
||||||
|
setSelectedRuntimeProviderKey('');
|
||||||
|
setModelIdInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedRuntimeProviderKey(parsedDefault.providerKey);
|
||||||
|
setModelIdInput(parsedDefault.modelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-xl rounded-3xl border-0 shadow-2xl bg-[#f3f1e9] dark:bg-card overflow-hidden">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-2xl font-serif font-normal tracking-tight">
|
||||||
|
{t('settingsDialog.modelLabel')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[15px] mt-1 text-foreground/70">
|
||||||
|
{t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 p-6 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-model-provider" className="text-[12px] text-foreground/70">{t('settingsDialog.modelProviderLabel')}</Label>
|
||||||
|
<select
|
||||||
|
id="agent-model-provider"
|
||||||
|
value={selectedRuntimeProviderKey}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextProvider = event.target.value;
|
||||||
|
setSelectedRuntimeProviderKey(nextProvider);
|
||||||
|
if (!modelIdInput.trim()) {
|
||||||
|
const option = runtimeProviderOptions.find((candidate) => candidate.runtimeProviderKey === nextProvider);
|
||||||
|
setModelIdInput(option?.configuredModelId || '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
<option value="">{t('settingsDialog.modelProviderPlaceholder')}</option>
|
||||||
|
{runtimeProviderOptions.map((option) => (
|
||||||
|
<option key={option.runtimeProviderKey} value={option.runtimeProviderKey}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-model-id" className="text-[12px] text-foreground/70">{t('settingsDialog.modelIdLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-model-id"
|
||||||
|
value={modelIdInput}
|
||||||
|
onChange={(event) => setModelIdInput(event.target.value)}
|
||||||
|
placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!!nextModelRef && (
|
||||||
|
<p className="text-[12px] font-mono text-foreground/70 break-all">
|
||||||
|
{t('settingsDialog.modelPreview')}: {nextModelRef}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{runtimeProviderOptions.length === 0 && (
|
||||||
|
<p className="text-[12px] text-amber-600 dark:text-amber-400">
|
||||||
|
{t('settingsDialog.modelProviderEmpty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseDefaultModel}
|
||||||
|
disabled={savingModel || !normalizedDefaultModelRef || isUsingDefaultModelInForm}
|
||||||
|
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('settingsDialog.useDefaultModel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('common:actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSaveModel()}
|
||||||
|
disabled={savingModel || !selectedRuntimeProviderKey || !trimmedModelId || !modelChanged}
|
||||||
|
className="h-9 text-[13px] font-medium rounded-full px-4 shadow-none"
|
||||||
|
>
|
||||||
|
{savingModel ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t('common:actions.save')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AgentSummary, AgentsSnapshot } from '@/types/agent';
|
|||||||
interface AgentsState {
|
interface AgentsState {
|
||||||
agents: AgentSummary[];
|
agents: AgentSummary[];
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
|
defaultModelRef: string | null;
|
||||||
configuredChannelTypes: string[];
|
configuredChannelTypes: string[];
|
||||||
channelOwners: Record<string, string>;
|
channelOwners: Record<string, string>;
|
||||||
channelAccountOwners: Record<string, string>;
|
channelAccountOwners: Record<string, string>;
|
||||||
@@ -14,6 +15,7 @@ interface AgentsState {
|
|||||||
fetchAgents: () => Promise<void>;
|
fetchAgents: () => Promise<void>;
|
||||||
createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise<void>;
|
createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise<void>;
|
||||||
updateAgent: (agentId: string, name: string) => Promise<void>;
|
updateAgent: (agentId: string, name: string) => Promise<void>;
|
||||||
|
updateAgentModel: (agentId: string, modelRef: string | null) => Promise<void>;
|
||||||
deleteAgent: (agentId: string) => Promise<void>;
|
deleteAgent: (agentId: string) => Promise<void>;
|
||||||
assignChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
|
assignChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
|
||||||
removeChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
|
removeChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
|
||||||
@@ -24,6 +26,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
|
|||||||
return snapshot ? {
|
return snapshot ? {
|
||||||
agents: snapshot.agents ?? [],
|
agents: snapshot.agents ?? [],
|
||||||
defaultAgentId: snapshot.defaultAgentId ?? 'main',
|
defaultAgentId: snapshot.defaultAgentId ?? 'main',
|
||||||
|
defaultModelRef: snapshot.defaultModelRef ?? null,
|
||||||
configuredChannelTypes: snapshot.configuredChannelTypes ?? [],
|
configuredChannelTypes: snapshot.configuredChannelTypes ?? [],
|
||||||
channelOwners: snapshot.channelOwners ?? {},
|
channelOwners: snapshot.channelOwners ?? {},
|
||||||
channelAccountOwners: snapshot.channelAccountOwners ?? {},
|
channelAccountOwners: snapshot.channelAccountOwners ?? {},
|
||||||
@@ -33,6 +36,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
|
|||||||
export const useAgentsStore = create<AgentsState>((set) => ({
|
export const useAgentsStore = create<AgentsState>((set) => ({
|
||||||
agents: [],
|
agents: [],
|
||||||
defaultAgentId: 'main',
|
defaultAgentId: 'main',
|
||||||
|
defaultModelRef: null,
|
||||||
configuredChannelTypes: [],
|
configuredChannelTypes: [],
|
||||||
channelOwners: {},
|
channelOwners: {},
|
||||||
channelAccountOwners: {},
|
channelAccountOwners: {},
|
||||||
@@ -83,6 +87,23 @@ export const useAgentsStore = create<AgentsState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateAgentModel: async (agentId: string, modelRef: string | null) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
|
||||||
|
`/api/agents/${encodeURIComponent(agentId)}/model`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ modelRef }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
set(applySnapshot(snapshot));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deleteAgent: async (agentId: string) => {
|
deleteAgent: async (agentId: string) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface AgentSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
modelDisplay: string;
|
modelDisplay: string;
|
||||||
|
modelRef?: string | null;
|
||||||
|
overrideModelRef?: string | null;
|
||||||
inheritedModel: boolean;
|
inheritedModel: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
@@ -13,6 +15,7 @@ export interface AgentSummary {
|
|||||||
export interface AgentsSnapshot {
|
export interface AgentsSnapshot {
|
||||||
agents: AgentSummary[];
|
agents: AgentSummary[];
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
|
defaultModelRef?: string | null;
|
||||||
configuredChannelTypes: string[];
|
configuredChannelTypes: string[];
|
||||||
channelOwners: Record<string, string>;
|
channelOwners: Record<string, string>;
|
||||||
channelAccountOwners: Record<string, string>;
|
channelAccountOwners: Record<string, string>;
|
||||||
|
|||||||
@@ -102,6 +102,101 @@ describe('agent config lifecycle', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes effective and override model refs in the snapshot', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: 'moonshot/kimi-k2.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{ id: 'main', name: 'Main', default: true },
|
||||||
|
{ id: 'coder', name: 'Coder', model: { primary: 'ark/ark-code-latest' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
|
||||||
|
const snapshot = await listAgentsSnapshot();
|
||||||
|
const main = snapshot.agents.find((agent) => agent.id === 'main');
|
||||||
|
const coder = snapshot.agents.find((agent) => agent.id === 'coder');
|
||||||
|
|
||||||
|
expect(snapshot.defaultModelRef).toBe('moonshot/kimi-k2.5');
|
||||||
|
expect(main).toMatchObject({
|
||||||
|
modelRef: 'moonshot/kimi-k2.5',
|
||||||
|
overrideModelRef: null,
|
||||||
|
inheritedModel: true,
|
||||||
|
modelDisplay: 'kimi-k2.5',
|
||||||
|
});
|
||||||
|
expect(coder).toMatchObject({
|
||||||
|
modelRef: 'ark/ark-code-latest',
|
||||||
|
overrideModelRef: 'ark/ark-code-latest',
|
||||||
|
inheritedModel: false,
|
||||||
|
modelDisplay: 'ark-code-latest',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates and clears per-agent model overrides', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: 'moonshot/kimi-k2.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{ id: 'main', name: 'Main', default: true },
|
||||||
|
{ id: 'coder', name: 'Coder' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { listAgentsSnapshot, updateAgentModel } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
|
await updateAgentModel('coder', 'ark/ark-code-latest');
|
||||||
|
let config = await readOpenClawJson();
|
||||||
|
let coder = ((config.agents as { list: Array<{ id: string; model?: { primary?: string } }> }).list)
|
||||||
|
.find((agent) => agent.id === 'coder');
|
||||||
|
expect(coder?.model?.primary).toBe('ark/ark-code-latest');
|
||||||
|
|
||||||
|
let snapshot = await listAgentsSnapshot();
|
||||||
|
let snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
|
||||||
|
expect(snapshotCoder).toMatchObject({
|
||||||
|
modelRef: 'ark/ark-code-latest',
|
||||||
|
overrideModelRef: 'ark/ark-code-latest',
|
||||||
|
inheritedModel: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgentModel('coder', null);
|
||||||
|
config = await readOpenClawJson();
|
||||||
|
coder = ((config.agents as { list: Array<{ id: string; model?: unknown }> }).list)
|
||||||
|
.find((agent) => agent.id === 'coder');
|
||||||
|
expect(coder?.model).toBeUndefined();
|
||||||
|
|
||||||
|
snapshot = await listAgentsSnapshot();
|
||||||
|
snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
|
||||||
|
expect(snapshotCoder).toMatchObject({
|
||||||
|
modelRef: 'moonshot/kimi-k2.5',
|
||||||
|
overrideModelRef: null,
|
||||||
|
inheritedModel: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid model ref formats when updating agent model', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: 'main', name: 'Main', default: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateAgentModel } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
|
await expect(updateAgentModel('main', 'invalid-model-ref')).rejects.toThrow(
|
||||||
|
'modelRef must be in "provider/model" format',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
|
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
|
||||||
await writeOpenClawJson({
|
await writeOpenClawJson({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, render, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { Agents } from '../../src/pages/Agents/index';
|
import { Agents } from '../../src/pages/Agents/index';
|
||||||
|
|
||||||
const hostApiFetchMock = vi.fn();
|
const hostApiFetchMock = vi.fn();
|
||||||
const subscribeHostEventMock = vi.fn();
|
const subscribeHostEventMock = vi.fn();
|
||||||
const fetchAgentsMock = vi.fn();
|
const fetchAgentsMock = vi.fn();
|
||||||
|
const updateAgentMock = vi.fn();
|
||||||
|
const updateAgentModelMock = vi.fn();
|
||||||
|
const refreshProviderSnapshotMock = vi.fn();
|
||||||
|
|
||||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
const { gatewayState, agentsState, providersState } = vi.hoisted(() => ({
|
||||||
gatewayState: {
|
gatewayState: {
|
||||||
status: { state: 'running', port: 18789 },
|
status: { state: 'running', port: 18789 },
|
||||||
},
|
},
|
||||||
agentsState: {
|
agentsState: {
|
||||||
agents: [] as Array<Record<string, unknown>>,
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
defaultModelRef: null as string | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
},
|
},
|
||||||
|
providersState: {
|
||||||
|
accounts: [] as Array<Record<string, unknown>>,
|
||||||
|
statuses: [] as Array<Record<string, unknown>>,
|
||||||
|
vendors: [] as Array<Record<string, unknown>>,
|
||||||
|
defaultAccountId: '' as string,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/stores/gateway', () => ({
|
vi.mock('@/stores/gateway', () => ({
|
||||||
@@ -25,12 +35,16 @@ vi.mock('@/stores/gateway', () => ({
|
|||||||
vi.mock('@/stores/agents', () => ({
|
vi.mock('@/stores/agents', () => ({
|
||||||
useAgentsStore: (selector?: (state: typeof agentsState & {
|
useAgentsStore: (selector?: (state: typeof agentsState & {
|
||||||
fetchAgents: typeof fetchAgentsMock;
|
fetchAgents: typeof fetchAgentsMock;
|
||||||
|
updateAgent: typeof updateAgentMock;
|
||||||
|
updateAgentModel: typeof updateAgentModelMock;
|
||||||
createAgent: ReturnType<typeof vi.fn>;
|
createAgent: ReturnType<typeof vi.fn>;
|
||||||
deleteAgent: ReturnType<typeof vi.fn>;
|
deleteAgent: ReturnType<typeof vi.fn>;
|
||||||
}) => unknown) => {
|
}) => unknown) => {
|
||||||
const state = {
|
const state = {
|
||||||
...agentsState,
|
...agentsState,
|
||||||
fetchAgents: fetchAgentsMock,
|
fetchAgents: fetchAgentsMock,
|
||||||
|
updateAgent: updateAgentMock,
|
||||||
|
updateAgentModel: updateAgentModelMock,
|
||||||
createAgent: vi.fn(),
|
createAgent: vi.fn(),
|
||||||
deleteAgent: vi.fn(),
|
deleteAgent: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -38,6 +52,18 @@ vi.mock('@/stores/agents', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/providers', () => ({
|
||||||
|
useProviderStore: (selector: (state: typeof providersState & {
|
||||||
|
refreshProviderSnapshot: typeof refreshProviderSnapshotMock;
|
||||||
|
}) => unknown) => {
|
||||||
|
const state = {
|
||||||
|
...providersState,
|
||||||
|
refreshProviderSnapshot: refreshProviderSnapshotMock,
|
||||||
|
};
|
||||||
|
return selector(state);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/host-api', () => ({
|
vi.mock('@/lib/host-api', () => ({
|
||||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -64,7 +90,16 @@ describe('Agents page status refresh', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
gatewayState.status = { state: 'running', port: 18789 };
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
agentsState.agents = [];
|
||||||
|
agentsState.defaultModelRef = null;
|
||||||
|
providersState.accounts = [];
|
||||||
|
providersState.statuses = [];
|
||||||
|
providersState.vendors = [];
|
||||||
|
providersState.defaultAccountId = '';
|
||||||
fetchAgentsMock.mockResolvedValue(undefined);
|
fetchAgentsMock.mockResolvedValue(undefined);
|
||||||
|
updateAgentMock.mockResolvedValue(undefined);
|
||||||
|
updateAgentModelMock.mockResolvedValue(undefined);
|
||||||
|
refreshProviderSnapshotMock.mockResolvedValue(undefined);
|
||||||
hostApiFetchMock.mockResolvedValue({
|
hostApiFetchMock.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
channels: [],
|
channels: [],
|
||||||
@@ -118,4 +153,65 @@ describe('Agents page status refresh', () => {
|
|||||||
expect(channelFetchCalls).toHaveLength(2);
|
expect(channelFetchCalls).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses "Use default model" as form fill only and disables it when already default', async () => {
|
||||||
|
agentsState.agents = [
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
isDefault: true,
|
||||||
|
modelDisplay: 'claude-opus-4.6',
|
||||||
|
modelRef: 'openrouter/anthropic/claude-opus-4.6',
|
||||||
|
overrideModelRef: null,
|
||||||
|
inheritedModel: true,
|
||||||
|
workspace: '~/.openclaw/workspace',
|
||||||
|
agentDir: '~/.openclaw/agents/main/agent',
|
||||||
|
mainSessionKey: 'agent:main:desk',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
agentsState.defaultModelRef = 'openrouter/anthropic/claude-opus-4.6';
|
||||||
|
providersState.accounts = [
|
||||||
|
{
|
||||||
|
id: 'openrouter-default',
|
||||||
|
label: 'OpenRouter',
|
||||||
|
vendorId: 'openrouter',
|
||||||
|
authMode: 'api_key',
|
||||||
|
model: 'openrouter/anthropic/claude-opus-4.6',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2026-03-24T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-24T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
providersState.statuses = [{ id: 'openrouter-default', hasKey: true }];
|
||||||
|
providersState.vendors = [
|
||||||
|
{ id: 'openrouter', name: 'OpenRouter', modelIdPlaceholder: 'anthropic/claude-opus-4.6' },
|
||||||
|
];
|
||||||
|
providersState.defaultAccountId = 'openrouter-default';
|
||||||
|
|
||||||
|
render(<Agents />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchAgentsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('settings'));
|
||||||
|
fireEvent.click(screen.getByText('settingsDialog.modelLabel').closest('button') as HTMLButtonElement);
|
||||||
|
|
||||||
|
const useDefaultButton = await screen.findByRole('button', { name: 'settingsDialog.useDefaultModel' });
|
||||||
|
const modelIdInput = screen.getByLabelText('settingsDialog.modelIdLabel');
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'common:actions.save' });
|
||||||
|
|
||||||
|
expect(useDefaultButton).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(modelIdInput, { target: { value: 'anthropic/claude-sonnet-4.5' } });
|
||||||
|
expect(useDefaultButton).toBeEnabled();
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
|
||||||
|
fireEvent.click(useDefaultButton);
|
||||||
|
|
||||||
|
expect(updateAgentModelMock).not.toHaveBeenCalled();
|
||||||
|
expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6');
|
||||||
|
expect(useDefaultButton).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
setOpenClawDefaultModelWithOverride: vi.fn(),
|
setOpenClawDefaultModelWithOverride: vi.fn(),
|
||||||
syncProviderConfigToOpenClaw: vi.fn(),
|
syncProviderConfigToOpenClaw: vi.fn(),
|
||||||
updateAgentModelProvider: vi.fn(),
|
updateAgentModelProvider: vi.fn(),
|
||||||
|
updateSingleAgentModelProvider: vi.fn(),
|
||||||
|
listAgentsSnapshot: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@electron/services/providers/provider-store', () => ({
|
vi.mock('@electron/services/providers/provider-store', () => ({
|
||||||
@@ -50,6 +52,11 @@ vi.mock('@electron/utils/openclaw-auth', () => ({
|
|||||||
setOpenClawDefaultModelWithOverride: mocks.setOpenClawDefaultModelWithOverride,
|
setOpenClawDefaultModelWithOverride: mocks.setOpenClawDefaultModelWithOverride,
|
||||||
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
|
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
|
||||||
updateAgentModelProvider: mocks.updateAgentModelProvider,
|
updateAgentModelProvider: mocks.updateAgentModelProvider,
|
||||||
|
updateSingleAgentModelProvider: mocks.updateSingleAgentModelProvider,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/agent-config', () => ({
|
||||||
|
listAgentsSnapshot: mocks.listAgentsSnapshot,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@electron/utils/logger', () => ({
|
vi.mock('@electron/utils/logger', () => ({
|
||||||
@@ -62,6 +69,7 @@ vi.mock('@electron/utils/logger', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
syncAgentModelOverrideToRuntime,
|
||||||
syncDefaultProviderToRuntime,
|
syncDefaultProviderToRuntime,
|
||||||
syncDeletedProviderToRuntime,
|
syncDeletedProviderToRuntime,
|
||||||
syncSavedProviderToRuntime,
|
syncSavedProviderToRuntime,
|
||||||
@@ -109,6 +117,8 @@ describe('provider-runtime-sync refresh strategy', () => {
|
|||||||
mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined);
|
mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined);
|
||||||
mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined);
|
mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined);
|
||||||
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
|
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
|
||||||
|
mocks.updateSingleAgentModelProvider.mockResolvedValue(undefined);
|
||||||
|
mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses debouncedReload after saving provider config', async () => {
|
it('uses debouncedReload after saving provider config', async () => {
|
||||||
@@ -142,4 +152,48 @@ describe('provider-runtime-sync refresh strategy', () => {
|
|||||||
expect(gateway.debouncedReload).not.toHaveBeenCalled();
|
expect(gateway.debouncedReload).not.toHaveBeenCalled();
|
||||||
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
|
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('syncs a targeted agent model override to runtime provider registry', async () => {
|
||||||
|
mocks.getAllProviders.mockResolvedValue([
|
||||||
|
createProvider({
|
||||||
|
id: 'ark',
|
||||||
|
type: 'ark',
|
||||||
|
model: 'doubao-pro',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
mocks.getProviderConfig.mockImplementation((providerType: string) => {
|
||||||
|
if (providerType === 'ark') {
|
||||||
|
return {
|
||||||
|
api: 'openai-completions',
|
||||||
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||||
|
apiKeyEnv: 'ARK_API_KEY',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api: 'openai-completions',
|
||||||
|
baseUrl: 'https://api.moonshot.cn/v1',
|
||||||
|
apiKeyEnv: 'MOONSHOT_API_KEY',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mocks.listAgentsSnapshot.mockResolvedValue({
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: 'coder',
|
||||||
|
modelRef: 'ark/ark-code-latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncAgentModelOverrideToRuntime('coder');
|
||||||
|
|
||||||
|
expect(mocks.updateSingleAgentModelProvider).toHaveBeenCalledWith(
|
||||||
|
'coder',
|
||||||
|
'ark',
|
||||||
|
expect.objectContaining({
|
||||||
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||||
|
api: 'openai-completions',
|
||||||
|
models: [{ id: 'ark-code-latest', name: 'ark-code-latest' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user