mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Merge pull request #116730 from openclaw/fix/lmstudio-no-tools-guard
* origin/fix/lmstudio-no-tools-guard: fix(chat): explain chat-only model limits feat(models): expose tool support to clients
This commit is contained in:
@@ -11956,6 +11956,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
public let available: Bool?
|
||||
public let contextwindow: Int?
|
||||
public let reasoning: Bool?
|
||||
public let supportstools: Bool?
|
||||
public let agentruntime: [String: AnyCodable]?
|
||||
public let apikeysupported: Bool?
|
||||
public let input: [AnyCodable]?
|
||||
@@ -11968,6 +11969,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
available: Bool? = nil,
|
||||
contextwindow: Int? = nil,
|
||||
reasoning: Bool? = nil,
|
||||
supportstools: Bool? = nil,
|
||||
agentruntime: [String: AnyCodable]? = nil,
|
||||
apikeysupported: Bool? = nil,
|
||||
input: [AnyCodable]? = nil)
|
||||
@@ -11979,6 +11981,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
self.available = available
|
||||
self.contextwindow = contextwindow
|
||||
self.reasoning = reasoning
|
||||
self.supportstools = supportstools
|
||||
self.agentruntime = agentruntime
|
||||
self.apikeysupported = apikeysupported
|
||||
self.input = input
|
||||
@@ -11992,6 +11995,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
case available
|
||||
case contextwindow = "contextWindow"
|
||||
case reasoning
|
||||
case supportstools = "supportsTools"
|
||||
case agentruntime = "agentRuntime"
|
||||
case apikeysupported = "apiKeySupported"
|
||||
case input
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ModelChoiceSchema = closedObject({
|
||||
available: Type.Optional(Type.Boolean()),
|
||||
contextWindow: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
reasoning: Type.Optional(Type.Boolean()),
|
||||
supportsTools: Type.Optional(Type.Boolean()),
|
||||
agentRuntime: Type.Optional(GatewayAgentRuntimeSchema),
|
||||
apiKeySupported: Type.Optional(Type.Boolean()),
|
||||
input: Type.Optional(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js";
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
|
||||
import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js";
|
||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||
import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js";
|
||||
import {
|
||||
buildProjectMemoryWriteInstruction,
|
||||
prepareProjectMemoryBootstrap,
|
||||
@@ -65,6 +66,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
getProviderRuntimeHandle: () => ProviderRuntimePluginHandle;
|
||||
isRawModelRun: boolean;
|
||||
markStage: (name: string) => void;
|
||||
modelToolsEnabled: boolean;
|
||||
proactiveSubagentOrchestration: boolean;
|
||||
sandbox?: SandboxContext;
|
||||
sandboxSessionKey: string;
|
||||
@@ -273,6 +275,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
const projectMemoryWriteInstruction = buildProjectMemoryWriteInstruction(
|
||||
attempt.preparedModelRuntime?.projectKey,
|
||||
);
|
||||
const extraSystemPrompt =
|
||||
[
|
||||
attempt.extraSystemPrompt,
|
||||
projectMemoryWriteInstruction,
|
||||
buildModelToolsUnavailablePrompt(params.modelToolsEnabled),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n\n") || undefined;
|
||||
|
||||
const attemptSystemPrompt = buildAttemptSystemPrompt({
|
||||
isRawModelRun: params.isRawModelRun,
|
||||
@@ -287,9 +297,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
workspaceDir: params.effectiveWorkspace,
|
||||
defaultThinkLevel: attempt.thinkLevel,
|
||||
reasoningLevel: attempt.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: projectMemoryWriteInstruction
|
||||
? [attempt.extraSystemPrompt, projectMemoryWriteInstruction].filter(Boolean).join("\n\n")
|
||||
: attempt.extraSystemPrompt,
|
||||
extraSystemPrompt,
|
||||
ownerNumbers: attempt.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt,
|
||||
|
||||
@@ -744,7 +744,8 @@ vi.mock("../../model-auth.js", () => ({
|
||||
resolveModelAuthMode: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../model-tool-support.js", () => ({
|
||||
vi.mock("../../model-tool-support.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../model-tool-support.js")>()),
|
||||
supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -292,6 +292,7 @@ export async function runEmbeddedAttempt(
|
||||
getProviderRuntimeHandle,
|
||||
isRawModelRun,
|
||||
markStage: (name) => prepStages.mark(name),
|
||||
modelToolsEnabled: toolsEnabled,
|
||||
proactiveSubagentOrchestration,
|
||||
sandbox: sandbox ?? undefined,
|
||||
sandboxSessionKey,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Documents model tool-support compatibility defaults.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsModelTools } from "./model-tool-support.js";
|
||||
import { buildModelToolsUnavailablePrompt, supportsModelTools } from "./model-tool-support.js";
|
||||
|
||||
describe("supportsModelTools", () => {
|
||||
it("defaults to true when the model has no compat override", () => {
|
||||
@@ -15,3 +15,13 @@ describe("supportsModelTools", () => {
|
||||
expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModelToolsUnavailablePrompt", () => {
|
||||
it("tells chat-only models not to invent tool-backed work", () => {
|
||||
expect(buildModelToolsUnavailablePrompt(true)).toBeUndefined();
|
||||
expect(buildModelToolsUnavailablePrompt(false)).toContain(
|
||||
"Do not claim that you ran commands, read or wrote files, browsed the web, generated media",
|
||||
);
|
||||
expect(buildModelToolsUnavailablePrompt(false)).toContain("switch to a tool-capable model");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Provider catalogs can opt a model out via `compat.supportsTools === false`;
|
||||
* absent metadata remains permissive for older catalog entries.
|
||||
*/
|
||||
const MODEL_TOOLS_UNAVAILABLE_PROMPT =
|
||||
"## Tool availability\n\nThis model cannot use tools in this run. Do not claim that you ran commands, read or wrote files, browsed the web, generated media, or performed any other tool-backed action. If a request requires tools, say they are unavailable in this chat and ask the user to switch to a tool-capable model.";
|
||||
|
||||
/** Returns whether a catalog model should be offered tool calls. */
|
||||
export function supportsModelTools(model: { compat?: unknown }): boolean {
|
||||
const compat =
|
||||
@@ -12,3 +15,8 @@ export function supportsModelTools(model: { compat?: unknown }): boolean {
|
||||
: undefined;
|
||||
return compat?.supportsTools !== false;
|
||||
}
|
||||
|
||||
/** Builds the bounded honesty guard for models that explicitly disable tools. */
|
||||
export function buildModelToolsUnavailablePrompt(modelToolsEnabled: boolean): string | undefined {
|
||||
return modelToolsEnabled ? undefined : MODEL_TOOLS_UNAVAILABLE_PROMPT;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ type ModelsListView = ModelCatalogBrowseView;
|
||||
type ModelsListEntry = Pick<
|
||||
ModelCatalogEntry,
|
||||
"alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning"
|
||||
> & { available?: boolean };
|
||||
> & { available?: boolean; supportsTools?: boolean };
|
||||
type ModelsListEntryWithCapabilities = ModelsListEntry & {
|
||||
agentRuntime?: GatewayAgentRuntime;
|
||||
apiKeySupported?: boolean;
|
||||
@@ -96,6 +96,9 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry {
|
||||
...(entry.alias ? { alias: entry.alias } : {}),
|
||||
...(contextWindow ? { contextWindow } : {}),
|
||||
...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}),
|
||||
...(typeof entry.compat?.supportsTools === "boolean"
|
||||
? { supportsTools: entry.compat.supportsTools }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ type ModelCatalogRpcEntry = {
|
||||
contextWindow?: number;
|
||||
input?: string[];
|
||||
reasoning?: boolean;
|
||||
supportsTools?: boolean;
|
||||
agentRuntime?: GatewayAgentRuntime;
|
||||
};
|
||||
|
||||
@@ -179,6 +180,7 @@ type ConfiguredProviderModelFixture = {
|
||||
name: string;
|
||||
alias: string;
|
||||
contextWindow: number;
|
||||
supportsTools?: boolean;
|
||||
};
|
||||
|
||||
const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) => ({
|
||||
@@ -200,6 +202,9 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) =
|
||||
id: params.modelId,
|
||||
name: params.name,
|
||||
contextWindow: params.contextWindow,
|
||||
...(params.supportsTools === undefined
|
||||
? {}
|
||||
: { compat: { supportsTools: params.supportsTools } }),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -213,6 +218,7 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture)
|
||||
alias: params.alias,
|
||||
provider: params.provider,
|
||||
contextWindow: params.contextWindow,
|
||||
...(params.supportsTools === undefined ? {} : { supportsTools: params.supportsTools }),
|
||||
});
|
||||
|
||||
describe("gateway server models + voicewake", () => {
|
||||
@@ -362,6 +368,9 @@ describe("gateway server models + voicewake", () => {
|
||||
if (expected.contextWindow !== undefined) {
|
||||
expect(models[0]?.contextWindow).toBe(expected.contextWindow);
|
||||
}
|
||||
if (expected.supportsTools !== undefined) {
|
||||
expect(models[0]?.supportsTools).toBe(expected.supportsTools);
|
||||
}
|
||||
};
|
||||
|
||||
test(
|
||||
@@ -757,6 +766,7 @@ describe("gateway server models + voicewake", () => {
|
||||
name: "Kimi K2.5 (Configured)",
|
||||
alias: "Kimi K2.5 (NVIDIA)",
|
||||
contextWindow: 32_000,
|
||||
supportsTools: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -935,6 +935,7 @@ export type ModelCatalogEntry = {
|
||||
available?: boolean;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
supportsTools?: boolean;
|
||||
agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime;
|
||||
input?: Array<"text" | "image" | "document">;
|
||||
apiKeySupported?: boolean;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
const sessionKey = "agent:main:main";
|
||||
const proofDir =
|
||||
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
|
||||
? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model")
|
||||
: null;
|
||||
|
||||
const models = [
|
||||
{
|
||||
id: "qwen3-8b",
|
||||
name: "Qwen3 8B",
|
||||
provider: "lmstudio",
|
||||
contextWindow: 32_768,
|
||||
supportsTools: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
contextWindow: 200_000,
|
||||
supportsTools: true,
|
||||
},
|
||||
];
|
||||
|
||||
function sessionsList(model: string, modelProvider: string) {
|
||||
return {
|
||||
count: 1,
|
||||
defaults: {
|
||||
contextTokens: 32_768,
|
||||
model: "qwen3-8b",
|
||||
modelProvider: "lmstudio",
|
||||
thinkingDefault: "off",
|
||||
thinkingLevels: [{ id: "off", label: "off" }],
|
||||
},
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: 32_768,
|
||||
displayName: "Local chat",
|
||||
hasActiveRun: false,
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label: "Local chat",
|
||||
model,
|
||||
modelProvider,
|
||||
status: "done",
|
||||
totalTokens: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("explains chat-only models and keeps model switching as the recovery path", async () => {
|
||||
if (proofDir) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
agentModel: "lmstudio/qwen3-8b",
|
||||
models,
|
||||
sessionKey,
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsList("qwen3-8b", "lmstudio"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const main = page.getByRole("main");
|
||||
const composer = main.locator(".agent-chat__composer-shell");
|
||||
const picker = composer.locator('[data-chat-model-select="true"]');
|
||||
const badge = picker.locator(".chat-controls__model-capability-badge");
|
||||
|
||||
await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable");
|
||||
await expect.poll(async () => (await badge.textContent())?.trim()).toBe("Chat only");
|
||||
await expect.poll(() => picker.getAttribute("aria-label")).toContain("Chat only");
|
||||
|
||||
if (proofDir) {
|
||||
await composer.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(proofDir, "01-desktop-chat-only-composer.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await picker.click();
|
||||
const localOption = composer.locator('[data-chat-model-option="lmstudio/qwen3-8b"]');
|
||||
const openAiOption = composer.locator('[data-chat-model-option="openai/gpt-5.5"]');
|
||||
await expect
|
||||
.poll(async () => (await localOption.textContent())?.replace(/\s+/g, " ").trim())
|
||||
.toContain("32.8k context · Chat only");
|
||||
await expect
|
||||
.poll(async () => (await openAiOption.textContent())?.includes("Chat only"))
|
||||
.toBe(false);
|
||||
|
||||
if (proofDir) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, "02-desktop-model-picker.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await composer.locator('[data-chat-model-provider="openai"]').click();
|
||||
await openAiOption.click();
|
||||
const patch = await gateway.waitForRequest("sessions.patch");
|
||||
expect(patch.params).toMatchObject({ key: sessionKey, model: "openai/gpt-5.5" });
|
||||
await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("available");
|
||||
await expect.poll(() => badge.count()).toBe(0);
|
||||
|
||||
const pickerDetails = composer.locator("details.chat-controls__model");
|
||||
if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) {
|
||||
await picker.click();
|
||||
}
|
||||
await composer.locator('[data-chat-model-provider="lmstudio"]').click();
|
||||
await localOption.click();
|
||||
await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable");
|
||||
if (await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open)) {
|
||||
await picker.click();
|
||||
}
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await expect.poll(() => picker.isVisible()).toBe(true);
|
||||
|
||||
if (proofDir) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, "03-mobile-chat-only-model.png"),
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) {
|
||||
await picker.click();
|
||||
}
|
||||
const menu = composer.locator(".chat-controls__inline-select-menu--combined");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await menu.boundingBox();
|
||||
return box !== null && box.x >= 0 && box.x + box.width <= 390;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
if (proofDir) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, "04-mobile-model-picker.png"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4902,6 +4902,9 @@ export const en: TranslationMap = {
|
||||
fastHelp: "Fast responses finish sooner and can use more of your usage limits.",
|
||||
speedUnsupported: "Speed control is not supported for this model.",
|
||||
contextWindow: "{count} context",
|
||||
chatOnly: "Chat only",
|
||||
chatOnlyHelp:
|
||||
"This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.",
|
||||
providerModels: "{provider} models",
|
||||
resetReasoning: "Reset to default ({level})",
|
||||
useDefaultReasoning: "Use default reasoning ({level})",
|
||||
|
||||
@@ -5352,6 +5352,45 @@ describe("chat model controls", () => {
|
||||
expect(modelOption?.closest("openclaw-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks chat-only models in the active control and picker", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: "qwen3-8b",
|
||||
modelProvider: "lmstudio",
|
||||
models: [
|
||||
{
|
||||
id: "qwen3-8b",
|
||||
name: "Qwen3 8B",
|
||||
provider: "lmstudio",
|
||||
contextWindow: 32_768,
|
||||
supportsTools: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
supportsTools: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const container = renderModelControls(state);
|
||||
const trigger = getChatModelSelect(container);
|
||||
|
||||
expect(trigger.dataset.chatModelTools).toBe("unavailable");
|
||||
expect(
|
||||
trigger.querySelector(".chat-controls__model-capability-badge")?.textContent?.trim(),
|
||||
).toBe("Chat only");
|
||||
expect(trigger.getAttribute("aria-label")).toContain("Chat only");
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-chat-model-option="lmstudio/qwen3-8b"]')
|
||||
?.querySelector(".chat-controls__model-option-meta")
|
||||
?.textContent?.trim(),
|
||||
).toBe("32.8k context · Chat only");
|
||||
expect(
|
||||
container.querySelector('[data-chat-model-option="openai/gpt-5.5"]')?.textContent,
|
||||
).not.toContain("Chat only");
|
||||
});
|
||||
|
||||
it("shows canonical OpenAI model names instead of command aliases", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: "gpt-5.5",
|
||||
|
||||
@@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & {
|
||||
contextWindow?: number;
|
||||
isDefault: boolean;
|
||||
provider: string;
|
||||
supportsTools?: boolean;
|
||||
};
|
||||
|
||||
const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly<Record<string, string>> = {
|
||||
@@ -210,6 +211,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
return {
|
||||
commitValue: isDefault ? "" : option.value,
|
||||
...(catalogEntry?.contextWindow ? { contextWindow: catalogEntry.contextWindow } : {}),
|
||||
...(typeof catalogEntry?.supportsTools === "boolean"
|
||||
? { supportsTools: catalogEntry.supportsTools }
|
||||
: {}),
|
||||
isDefault,
|
||||
value: option.value,
|
||||
label: resolveChatModelPickerLabel(option.value, option.label, props.modelCatalog),
|
||||
@@ -403,8 +407,21 @@ function renderChatModelReasoningSelect(params: {
|
||||
} = params;
|
||||
const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel);
|
||||
const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel);
|
||||
const triggerTitle = `${triggerModel} · ${triggerThinking}`;
|
||||
const triggerLabel = triggerTitle;
|
||||
const defaultModelOption = modelOptions.find((option) => option.isDefault);
|
||||
const activeModelOption =
|
||||
selectedModelValue === ""
|
||||
? defaultModelOption
|
||||
: modelOptions.find((option) => option.value === selectedModelValue);
|
||||
const selectedModelOption = activeModelOption ?? modelOptions[0];
|
||||
const modelToolsUnavailable = activeModelOption?.supportsTools === false;
|
||||
const triggerTitle = [
|
||||
triggerModel,
|
||||
triggerThinking,
|
||||
modelToolsUnavailable ? t("chat.modelControls.chatOnly") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const triggerLabel = `${triggerModel} · ${triggerThinking}`;
|
||||
const sliderStops = thinkingOptions.filter((option) => option.value !== "");
|
||||
const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue);
|
||||
const hasThinkingOverride = selectedThinkingValue !== "";
|
||||
@@ -530,7 +547,6 @@ function renderChatModelReasoningSelect(params: {
|
||||
providerGroups.set(option.provider, [option]);
|
||||
}
|
||||
}
|
||||
const defaultModelOption = modelOptions.find((option) => option.isDefault);
|
||||
const orderedProviderGroups = [...providerGroups];
|
||||
const defaultProviderIndex = orderedProviderGroups.findIndex(
|
||||
([provider]) => provider === defaultModelOption?.provider,
|
||||
@@ -541,21 +557,22 @@ function renderChatModelReasoningSelect(params: {
|
||||
orderedProviderGroups.unshift(defaultProviderGroup);
|
||||
}
|
||||
}
|
||||
const selectedModelOption =
|
||||
(selectedModelValue === ""
|
||||
? defaultModelOption
|
||||
: modelOptions.find((option) => option.value === selectedModelValue)) ?? modelOptions[0];
|
||||
const selectedProvider =
|
||||
selectedModelOption?.provider ?? orderedProviderGroups[0]?.[0] ?? "other";
|
||||
const renderModelOption = (entry: ChatModelProviderOption) => {
|
||||
const selected =
|
||||
entry.value === selectedModelValue || (entry.isDefault && selectedModelValue === "");
|
||||
const modelLabel = formatCombinedPickerModelOptionLabel(entry);
|
||||
const contextLabel = entry.contextWindow
|
||||
? t("chat.modelControls.contextWindow", {
|
||||
count: formatCompactTokenCount(entry.contextWindow),
|
||||
})
|
||||
: "";
|
||||
const modelMeta = [
|
||||
entry.contextWindow
|
||||
? t("chat.modelControls.contextWindow", {
|
||||
count: formatCompactTokenCount(entry.contextWindow),
|
||||
})
|
||||
: "",
|
||||
entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return html`
|
||||
<div class="chat-controls__combined-model">
|
||||
<button
|
||||
@@ -592,8 +609,8 @@ function renderChatModelReasoningSelect(params: {
|
||||
>`
|
||||
: ""}
|
||||
</span>
|
||||
${contextLabel
|
||||
? html`<span class="chat-controls__model-option-meta">${contextLabel}</span>`
|
||||
${modelMeta
|
||||
? html`<span class="chat-controls__model-option-meta">${modelMeta}</span>`
|
||||
: ""}
|
||||
</span>
|
||||
${selected
|
||||
@@ -619,6 +636,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
data-chat-select-value=${selectedModelValue}
|
||||
data-chat-thinking-value=${selectedThinkingValue}
|
||||
data-chat-thinking-disabled=${thinkingDisabled ? "true" : "false"}
|
||||
data-chat-model-tools=${modelToolsUnavailable ? "unavailable" : "available"}
|
||||
aria-label="${t("chat.selectors.model")}, ${t(
|
||||
"chat.selectors.thinkingLevel",
|
||||
)}: ${triggerTitle}"
|
||||
@@ -629,6 +647,16 @@ function renderChatModelReasoningSelect(params: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
${modelToolsUnavailable
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.modelControls.chatOnlyHelp")}>
|
||||
<span class="chat-controls__model-capability-badge" aria-hidden="true">
|
||||
${icons.alertTriangle}
|
||||
<span>${t("chat.modelControls.chatOnly")}</span>
|
||||
</span>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
<span class="chat-controls__inline-select-label">${triggerLabel}</span>
|
||||
<span class="chat-controls__inline-select-icon" aria-hidden="true">
|
||||
${icons.chevronDown}
|
||||
|
||||
@@ -4287,6 +4287,27 @@ openclaw-chat-video-player {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-controls__model-capability-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn) 34%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--text));
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-controls__model-capability-badge svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Keep the chevron pinned right when the mobile trigger stretches. */
|
||||
.chat-controls__inline-select-icon {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -207,6 +207,8 @@ export type ControlUiMockGatewayScenario = {
|
||||
name: string;
|
||||
provider: string;
|
||||
available?: boolean;
|
||||
contextWindow?: number;
|
||||
supportsTools?: boolean;
|
||||
}>;
|
||||
/** Operator scopes returned by the mocked connect handshake. */
|
||||
operatorScopes?: string[];
|
||||
|
||||
Reference in New Issue
Block a user