mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 08:52:46 +00:00
added model picker
This commit is contained in:
@@ -171,6 +171,24 @@ export interface AgentBudgetResponse {
|
||||
|
||||
export type AgentBudgetPatch = Partial<Record<AgentBudgetKey, number | null>>;
|
||||
|
||||
export interface AgentProviderModel {
|
||||
key: string;
|
||||
name: string;
|
||||
contextWindow: number | null;
|
||||
local: boolean;
|
||||
available: boolean;
|
||||
missing: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface AgentProviderModelsResponse {
|
||||
agentId: string;
|
||||
known: boolean;
|
||||
currentModel: string | null;
|
||||
provider: string | null;
|
||||
models: AgentProviderModel[];
|
||||
}
|
||||
|
||||
// ── Agent skills (per-agent allowlist) ──
|
||||
|
||||
export interface AgentSkillSummary {
|
||||
|
||||
@@ -462,6 +462,50 @@ const updateSubagentsConfig: RequestHandler = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderModels: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
return res.json(ocService.getAgentProviderModels(agent.openclawAgentId));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProviderModel: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
const convRepo = AppDataSource.getRepository(Conversation);
|
||||
|
||||
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as { model?: string; conversationId?: number | string };
|
||||
const modelKey = typeof body.model === 'string' ? body.model : '';
|
||||
|
||||
let sessionKey: string | null = null;
|
||||
if (body.conversationId !== undefined && body.conversationId !== null) {
|
||||
const conv = await convRepo.findOneBy({ _id: Number(body.conversationId) });
|
||||
sessionKey = conv?.sessionKey || (conv ? String(conv._id) : null);
|
||||
}
|
||||
|
||||
const result = await ocService.setAgentProviderModel(
|
||||
agent.openclawAgentId,
|
||||
modelKey,
|
||||
sessionKey
|
||||
);
|
||||
if (!result.ok) return res.status(400).json(result);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const serveWorkspaceUpload: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
@@ -497,4 +541,6 @@ export {
|
||||
updateSkillsConfig,
|
||||
getSubagentsConfig,
|
||||
updateSubagentsConfig,
|
||||
getProviderModels,
|
||||
updateProviderModel,
|
||||
};
|
||||
|
||||
@@ -397,6 +397,74 @@ paths:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
/agent/{id}/provider-models:
|
||||
get:
|
||||
tags:
|
||||
- agent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
operationId: getAgentProviderModels
|
||||
summary: List models for the agent's currently configured provider
|
||||
description: >
|
||||
Returns the agent's current model and every model exposed by that
|
||||
provider (derived from the `provider/model` prefix). Use to power a
|
||||
scoped model picker — no provider switching is offered because that
|
||||
would require different credentials.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/id'
|
||||
responses:
|
||||
200:
|
||||
description: Provider-scoped model catalog
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentProviderModelsResponse'
|
||||
404:
|
||||
description: Agent not found
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
patch:
|
||||
tags:
|
||||
- agent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
operationId: updateAgentProviderModel
|
||||
summary: Hot-swap the agent's model (same provider)
|
||||
description: >
|
||||
Updates the agent's configured model via the Gateway RPC `agents.update`
|
||||
so the change takes effect on the next turn without a gateway restart.
|
||||
When `conversationId` is provided, any pinned `modelOverride` on that
|
||||
session is cleared via `sessions.patch` so the running chat picks up
|
||||
the new default immediately.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentProviderModelPatch'
|
||||
responses:
|
||||
200:
|
||||
description: Updated model configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentProviderModelMutationResponse'
|
||||
400:
|
||||
description: Model update failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentProviderModelMutationResponse'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
/agent/{id}/skills:
|
||||
get:
|
||||
tags:
|
||||
@@ -710,6 +778,77 @@ components:
|
||||
config:
|
||||
$ref: '#/components/schemas/agentBudgetResponse'
|
||||
#
|
||||
agentProviderModel:
|
||||
type: object
|
||||
description: A single model entry surfaced by `openclaw models list --provider <id>`.
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Full model key (e.g. `google/gemini-2.5-pro`).
|
||||
name:
|
||||
type: string
|
||||
contextWindow:
|
||||
type: integer
|
||||
nullable: true
|
||||
local:
|
||||
type: boolean
|
||||
available:
|
||||
type: boolean
|
||||
description: Whether the agent currently has credentials for this provider.
|
||||
missing:
|
||||
type: boolean
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
#
|
||||
agentProviderModelsResponse:
|
||||
type: object
|
||||
properties:
|
||||
agentId:
|
||||
type: string
|
||||
known:
|
||||
type: boolean
|
||||
currentModel:
|
||||
type: string
|
||||
nullable: true
|
||||
provider:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Provider id derived from the `provider/model` prefix.
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/agentProviderModel'
|
||||
#
|
||||
agentProviderModelPatch:
|
||||
type: object
|
||||
required:
|
||||
- model
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
description: Full model key (must belong to the agent's current provider).
|
||||
conversationId:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: When provided, also clears any session override so the change applies mid-chat.
|
||||
#
|
||||
agentProviderModelMutationResponse:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
restartHint:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Present when a gateway restart or new conversation is required.
|
||||
config:
|
||||
$ref: '#/components/schemas/agentProviderModelsResponse'
|
||||
#
|
||||
agentSkillSummary:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -36,6 +36,11 @@ router
|
||||
.get(auth, validate.id, controller.getBudget)
|
||||
.patch(auth, validate.budgetPatch, controller.updateBudget);
|
||||
|
||||
router
|
||||
.route('/agent/:id(\\d+)/provider-models')
|
||||
.get(auth, validate.id, controller.getProviderModels)
|
||||
.patch(auth, validate.providerModelPatch, controller.updateProviderModel);
|
||||
|
||||
router
|
||||
.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
|
||||
.get(auth, controller.getSessionSettings)
|
||||
|
||||
@@ -142,4 +142,27 @@ export default {
|
||||
return true;
|
||||
}),
|
||||
]),
|
||||
|
||||
providerModelPatch: validate([
|
||||
param('id').isInt().withMessage('Incorrect request url'),
|
||||
body().custom((value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Body must be an object.');
|
||||
}
|
||||
const v = value as Record<string, unknown>;
|
||||
const allowed = ['model', 'conversationId'];
|
||||
const unknown = Object.keys(v).find((k) => !allowed.includes(k));
|
||||
if (unknown) throw new Error(`Unknown field: ${unknown}`);
|
||||
if (typeof v.model !== 'string' || !v.model.trim()) {
|
||||
throw new Error('"model" must be a non-empty string.');
|
||||
}
|
||||
if (v.conversationId !== undefined && v.conversationId !== null) {
|
||||
const n = Number(v.conversationId);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error('"conversationId" must be a positive integer when provided.');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/* eslint-disable no-console */
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import {
|
||||
AgentProviderModel,
|
||||
AgentProviderModelsResponse,
|
||||
OpenclawAgentEntry,
|
||||
OpenclawConfig,
|
||||
} from '../../@types/openclaw';
|
||||
import { gateway, ocExec } from '../openclawGateway';
|
||||
import { openclawConfigPath } from './paths';
|
||||
import { errMsg, execErrText } from '../../utils/errors';
|
||||
|
||||
const CLI_OPTS = {
|
||||
cwd: os.homedir(),
|
||||
env: { ...process.env, NO_COLOR: '1' } as NodeJS.ProcessEnv,
|
||||
timeout: 20000,
|
||||
};
|
||||
|
||||
function readConfig(): OpenclawConfig | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(openclawConfigPath(), 'utf-8')) as OpenclawConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findAgentIndex(config: OpenclawConfig | null, openclawAgentId: string): number {
|
||||
const list = config?.agents?.list ?? [];
|
||||
return list.findIndex((a) => a?.id === openclawAgentId);
|
||||
}
|
||||
|
||||
function extractModelString(entry: OpenclawAgentEntry | undefined): string | null {
|
||||
const m = entry?.model;
|
||||
if (!m) return null;
|
||||
if (typeof m === 'string') return m;
|
||||
if (typeof m === 'object') return m.primary ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `provider/model` → `provider`. Returns null when slashless (local, alias, etc.). */
|
||||
function parseProvider(modelKey: string | null): string | null {
|
||||
if (!modelKey) return null;
|
||||
const idx = modelKey.indexOf('/');
|
||||
return idx > 0 ? modelKey.slice(0, idx) : null;
|
||||
}
|
||||
|
||||
interface OcModelsListRow {
|
||||
key?: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
input?: string;
|
||||
local?: boolean;
|
||||
available?: boolean;
|
||||
missing?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider memoisation of the CLI catalog. `openclaw models list --all
|
||||
* --provider <id> --json` costs ~1.5–2s per invocation and the result is
|
||||
* effectively static per OpenClaw build, so we cache by provider for a short
|
||||
* window. Model swaps mutate config but never the catalog, so no explicit
|
||||
* invalidation is needed — the TTL covers both "new build installed" and
|
||||
* "user configured a new alias" cases.
|
||||
*/
|
||||
const PROVIDER_MODELS_TTL_MS = 5 * 60 * 1000;
|
||||
const providerModelsCache = new Map<string, { at: number; models: AgentProviderModel[] }>();
|
||||
|
||||
function listProviderModelsViaCli(provider: string): AgentProviderModel[] {
|
||||
const cached = providerModelsCache.get(provider);
|
||||
if (cached && Date.now() - cached.at < PROVIDER_MODELS_TTL_MS) {
|
||||
return cached.models;
|
||||
}
|
||||
try {
|
||||
const out = ocExec(['models', 'list', '--provider', provider, '--all', '--json'], {
|
||||
...CLI_OPTS,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const parsed = JSON.parse(out) as { models?: OcModelsListRow[] };
|
||||
const rows = Array.isArray(parsed?.models) ? parsed.models : [];
|
||||
const models = rows
|
||||
.filter((r): r is OcModelsListRow & { key: string } => typeof r?.key === 'string' && !!r.key)
|
||||
.map((r) => ({
|
||||
key: r.key,
|
||||
name: typeof r.name === 'string' && r.name ? r.name : r.key,
|
||||
contextWindow: typeof r.contextWindow === 'number' ? r.contextWindow : null,
|
||||
local: !!r.local,
|
||||
available: !!r.available,
|
||||
missing: !!r.missing,
|
||||
tags: Array.isArray(r.tags) ? r.tags.filter((t): t is string => typeof t === 'string') : [],
|
||||
}))
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
providerModelsCache.set(provider, { at: Date.now(), models });
|
||||
return models;
|
||||
} catch (err) {
|
||||
console.error('[agent-provider-models] list failed:', execErrText(err));
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentProviderModels(openclawAgentId: string): AgentProviderModelsResponse {
|
||||
const config = readConfig();
|
||||
const agentIndex = findAgentIndex(config, openclawAgentId);
|
||||
const entry = agentIndex >= 0 ? config?.agents?.list?.[agentIndex] : undefined;
|
||||
const currentModel =
|
||||
extractModelString(entry) ??
|
||||
(typeof config?.agents?.defaults?.model?.primary === 'string'
|
||||
? (config.agents.defaults.model.primary as string)
|
||||
: null);
|
||||
const provider = parseProvider(currentModel);
|
||||
|
||||
return {
|
||||
agentId: openclawAgentId,
|
||||
known: agentIndex >= 0,
|
||||
currentModel,
|
||||
provider,
|
||||
models: provider ? listProviderModelsViaCli(provider) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetAgentProviderModelResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
restartHint?: string | null;
|
||||
config?: AgentProviderModelsResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-apply a model change for the given agent. Uses the Gateway RPC
|
||||
* `agents.update` so the daemon's in-memory registry picks it up, then clears
|
||||
* any pinned `modelOverride` on the active session (if any) via
|
||||
* `sessions.patch { key, model: null }` so the next turn honours the new
|
||||
* agent default. Falls back to the CLI when the gateway is unreachable.
|
||||
*/
|
||||
export async function setAgentProviderModel(
|
||||
openclawAgentId: string,
|
||||
modelKey: string,
|
||||
sessionKey: string | null
|
||||
): Promise<SetAgentProviderModelResult> {
|
||||
const config = readConfig();
|
||||
const agentIndex = findAgentIndex(config, openclawAgentId);
|
||||
if (agentIndex < 0) {
|
||||
return { ok: false, error: `Agent "${openclawAgentId}" not found in openclaw config.` };
|
||||
}
|
||||
|
||||
const trimmed = modelKey.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: '"model" must be a non-empty string.' };
|
||||
}
|
||||
|
||||
let restartHint: string | null = null;
|
||||
let wroteViaGateway = false;
|
||||
|
||||
const gwReady = await gateway.ensureConnected();
|
||||
if (gwReady) {
|
||||
try {
|
||||
await gateway.request(
|
||||
'agents.update',
|
||||
{ agentId: openclawAgentId, model: trimmed },
|
||||
{ timeoutMs: 5000 }
|
||||
);
|
||||
wroteViaGateway = true;
|
||||
} catch (err) {
|
||||
console.warn('[agent-provider-models] agents.update failed, falling back:', errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (!wroteViaGateway) {
|
||||
try {
|
||||
const cfgPath = `agents.list[${agentIndex}].model`;
|
||||
ocExec(['config', 'set', cfgPath, JSON.stringify(trimmed), '--strict-json'], CLI_OPTS);
|
||||
restartHint = 'Restart the gateway or start a new conversation to apply.';
|
||||
} catch (err) {
|
||||
const stderr = execErrText(err);
|
||||
return { ok: false, error: stderr || 'Failed to update agent model.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (wroteViaGateway && sessionKey) {
|
||||
try {
|
||||
const fullKey = `agent:${openclawAgentId}:${sessionKey}`;
|
||||
await gateway.request('sessions.patch', { key: fullKey, model: null }, { timeoutMs: 5000 });
|
||||
} catch (err) {
|
||||
restartHint = `Session override could not be cleared: ${errMsg(err)}. Start a new conversation to apply.`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
restartHint,
|
||||
config: getAgentProviderModels(openclawAgentId),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
copyFileToWorkspace,
|
||||
} from './workspace';
|
||||
export { getAgentModel, getAgentModelsForOpenclawIds } from './config';
|
||||
export { getAgentProviderModels, setAgentProviderModel } from './agentProviderModels';
|
||||
export { getAgentBudget, setAgentBudget, BUDGET_FIELDS } from './budget';
|
||||
export { getAgentSkillsConfig, setAgentSkills } from './agentSkills';
|
||||
export { getAgentSubagentsConfig, setAgentSubagents } from './agentSubagents';
|
||||
|
||||
@@ -126,6 +126,31 @@ export interface AgentSubagentsMutationResponse {
|
||||
config?: AgentSubagentsResponse;
|
||||
}
|
||||
|
||||
export interface AgentProviderModel {
|
||||
key: string;
|
||||
name: string;
|
||||
contextWindow: number | null;
|
||||
local: boolean;
|
||||
available: boolean;
|
||||
missing: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface AgentProviderModelsResponse {
|
||||
agentId: string;
|
||||
known: boolean;
|
||||
currentModel: string | null;
|
||||
provider: string | null;
|
||||
models: AgentProviderModel[];
|
||||
}
|
||||
|
||||
export interface AgentProviderModelMutationResponse {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
restartHint?: string | null;
|
||||
config?: AgentProviderModelsResponse;
|
||||
}
|
||||
|
||||
export const WORKSPACE_TAB_FILES = [
|
||||
{ label: 'AGENTS', file: 'AGENTS.md' },
|
||||
{ label: 'SOUL', file: 'SOUL.md' },
|
||||
@@ -273,6 +298,24 @@ export const agentsApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: (_res, _err, { agentId }) => [{ type: 'AgentSubagents', id: agentId }],
|
||||
}),
|
||||
getAgentProviderModels: build.query<AgentProviderModelsResponse, string>({
|
||||
query: (agentId) => `/agent/${agentId}/provider-models`,
|
||||
providesTags: (_res, _err, agentId) => [{ type: 'AgentProviderModels', id: agentId }],
|
||||
}),
|
||||
updateAgentProviderModel: build.mutation<
|
||||
AgentProviderModelMutationResponse,
|
||||
{ agentId: string; model: string; conversationId?: number | string }
|
||||
>({
|
||||
query: ({ agentId, model, conversationId }) => ({
|
||||
url: `/agent/${agentId}/provider-models`,
|
||||
method: 'PATCH',
|
||||
body: { model, ...(conversationId !== undefined ? { conversationId } : {}) },
|
||||
}),
|
||||
invalidatesTags: (_res, _err, { agentId }) => [
|
||||
{ type: 'AgentProviderModels', id: agentId },
|
||||
'Agent',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -294,4 +337,6 @@ export const {
|
||||
useUpdateAgentSkillsMutation,
|
||||
useGetAgentSubagentsQuery,
|
||||
useUpdateAgentSubagentsMutation,
|
||||
useGetAgentProviderModelsQuery,
|
||||
useUpdateAgentProviderModelMutation,
|
||||
} = agentsApi;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AgentModelPicker } from './ui/AgentModelPicker';
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
ButtonBase,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { CheckCircle, ExpandMore, WarningAmber } from '@mui/icons-material';
|
||||
import {
|
||||
useGetAgentProviderModelsQuery,
|
||||
useUpdateAgentProviderModelMutation,
|
||||
type AgentProviderModel,
|
||||
} from '../../../../entities/agent';
|
||||
|
||||
interface AgentModelPickerProps {
|
||||
agentId: string;
|
||||
currentModel?: string | null;
|
||||
conversationId?: number | string;
|
||||
}
|
||||
|
||||
function stripProvider(key: string): string {
|
||||
const idx = key.indexOf('/');
|
||||
return idx > 0 ? key.slice(idx + 1) : key;
|
||||
}
|
||||
|
||||
function parseProvider(key: string | null | undefined): string | null {
|
||||
if (!key) return null;
|
||||
const idx = key.indexOf('/');
|
||||
return idx > 0 ? key.slice(0, idx) : null;
|
||||
}
|
||||
|
||||
function labelFor(model: AgentProviderModel | null | undefined, fallback: string): string {
|
||||
if (!model) return fallback;
|
||||
return model.name || stripProvider(model.key);
|
||||
}
|
||||
|
||||
/** `1000000` → `1M`, `272000` → `272k`, `4096` → `4k`. */
|
||||
function formatCtx(n: number): string {
|
||||
if (n >= 1_000_000) {
|
||||
const m = n / 1_000_000;
|
||||
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
|
||||
}
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function AgentModelPicker({
|
||||
agentId,
|
||||
currentModel,
|
||||
conversationId,
|
||||
}: AgentModelPickerProps) {
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingKey, setPendingKey] = useState<string | null>(null);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
const [prevAgentId, setPrevAgentId] = useState(agentId);
|
||||
if (agentId !== prevAgentId) {
|
||||
setPrevAgentId(agentId);
|
||||
if (open) setOpen(false);
|
||||
if (pendingKey !== null) setPendingKey(null);
|
||||
if (hint !== null) setHint(null);
|
||||
}
|
||||
|
||||
const { currentData, isFetching } = useGetAgentProviderModelsQuery(agentId, {
|
||||
skip: !agentId || !open,
|
||||
});
|
||||
const [update, { isLoading: saving }] = useUpdateAgentProviderModelMutation();
|
||||
|
||||
const provider = parseProvider(currentModel);
|
||||
if (!agentId || !currentModel || !provider) return null;
|
||||
|
||||
const data = currentData && currentData.provider === provider ? currentData : null;
|
||||
const effectiveCurrent = data?.currentModel ?? currentModel;
|
||||
const matched = data?.models.find((m) => m.key === effectiveCurrent) ?? null;
|
||||
const currentLabel = labelFor(matched, stripProvider(effectiveCurrent));
|
||||
|
||||
async function handlePick(model: AgentProviderModel) {
|
||||
if (model.key === effectiveCurrent || saving) return;
|
||||
setPendingKey(model.key);
|
||||
setHint(null);
|
||||
try {
|
||||
const result = await update({ agentId, model: model.key, conversationId }).unwrap();
|
||||
setHint(result.restartHint ?? null);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'data' in err
|
||||
? ((err as { data?: { error?: string } }).data?.error ?? 'Failed to change model.')
|
||||
: 'Failed to change model.';
|
||||
setHint(msg);
|
||||
} finally {
|
||||
setPendingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Tooltip title={`Switch ${provider} model`} placement="bottom-start">
|
||||
<ButtonBase
|
||||
ref={anchorRef}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
transition: 'all 0.12s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.selected',
|
||||
color: 'text.primary',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: 'text.disabled', textTransform: 'lowercase', mr: 0.25 }}
|
||||
>
|
||||
{provider}
|
||||
</Box>
|
||||
<Box component="span">{currentLabel}</Box>
|
||||
{saving ? (
|
||||
<CircularProgress size={10} sx={{ ml: 0.5 }} />
|
||||
) : (
|
||||
<ExpandMore sx={{ fontSize: 14, ml: 0.25 }} />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
|
||||
{hint && (
|
||||
<Tooltip title={hint}>
|
||||
<WarningAmber sx={{ fontSize: 14, color: 'warning.main' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorRef.current}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 340,
|
||||
mt: 0.5,
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? theme.palette.grey[900] : theme.palette.grey[100],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
list: {
|
||||
sx: {
|
||||
py: 0,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: 'divider',
|
||||
borderRadius: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFetching && !data && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 2 }}>
|
||||
<CircularProgress size={14} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Loading {provider} models…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data && data.models.length === 0 && !isFetching && (
|
||||
<Box sx={{ px: 2, py: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No {provider} models available.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data?.models.map((m, idx) => {
|
||||
const selected = m.key === effectiveCurrent;
|
||||
const isPending = pendingKey === m.key;
|
||||
const displayName = labelFor(m, stripProvider(m.key));
|
||||
return (
|
||||
<Box key={m.key}>
|
||||
{idx > 0 && <Divider sx={{ borderStyle: 'dashed', opacity: 0.5 }} />}
|
||||
<MenuItem
|
||||
onClick={() => void handlePick(m)}
|
||||
selected={selected}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
bgcolor: 'transparent',
|
||||
transition: 'background-color 0.12s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.selected',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'action.selected',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.selected',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: selected ? 600 : 500,
|
||||
color: selected ? 'success.main' : 'text.primary',
|
||||
lineHeight: 1.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Typography>
|
||||
{selected && (
|
||||
<CheckCircle sx={{ fontSize: 14, color: 'success.main', flexShrink: 0 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: 'text.disabled',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: '0.66rem',
|
||||
lineHeight: 1.4,
|
||||
mt: 0.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{m.key}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
{m.local && (
|
||||
<Chip
|
||||
label="local"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary',
|
||||
'& .MuiChip-label': { px: 0.75 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{m.contextWindow ? (
|
||||
<Chip
|
||||
label={formatCtx(m.contextWindow)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.66rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.secondary',
|
||||
'& .MuiChip-label': { px: 0.9 },
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isPending && <CircularProgress size={14} sx={{ ml: 0.5 }} />}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export const baseApi = createApi({
|
||||
'AgentBudget',
|
||||
'AgentSkills',
|
||||
'AgentSubagents',
|
||||
'AgentProviderModels',
|
||||
],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, TextField, IconButton, Typography, CircularProgress } from '@mui/material';
|
||||
import { Box, TextField, IconButton, Typography, CircularProgress, Stack } from '@mui/material';
|
||||
import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material';
|
||||
import { Link } from 'react-router';
|
||||
import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent';
|
||||
import { AgentModelPicker } from '../../../features/agent/provider-model';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
agentId: string;
|
||||
@@ -98,19 +99,26 @@ export default function ChatHeader({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Stack sx={{ flex: 1, minWidth: 0 }} spacing={0.25}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<AgentModelPicker
|
||||
agentId={agentId}
|
||||
currentModel={agent.model ?? null}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
</Stack>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onToggleSessionSettings}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-client",
|
||||
"version": "2.4.5",
|
||||
"version": "2.4.6",
|
||||
"description": "Web-based chat interface for OpenClaw AI agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user