mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
ui: add chat thinking selector
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
|
||||
- Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.
|
||||
- Gateway/security: scope loopback browser-origin auth throttling by normalized origin so one localhost Control UI tab cannot lock out a different localhost browser origin after repeated auth failures.
|
||||
- Node exec approvals: keep node-host `system.run` approvals bound to the prepared execution plan, so script-drift revalidation still runs after agent-side approval forwarding.
|
||||
|
||||
@@ -743,8 +743,8 @@
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 140px;
|
||||
max-width: 300px;
|
||||
min-width: 98px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.chat-controls__session-row {
|
||||
@@ -755,8 +755,13 @@
|
||||
}
|
||||
|
||||
.chat-controls__model {
|
||||
min-width: 170px;
|
||||
max-width: 320px;
|
||||
min-width: 124px;
|
||||
max-width: 206px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking-select {
|
||||
min-width: 88px;
|
||||
max-width: 118px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
@@ -781,13 +786,17 @@
|
||||
.chat-controls__session select {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 300px;
|
||||
max-width: 190px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-controls__model select {
|
||||
max-width: 320px;
|
||||
max-width: 206px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking-select select {
|
||||
max-width: 118px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
@@ -818,6 +827,11 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.chat-controls__thinking-select {
|
||||
min-width: 130px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -863,6 +877,10 @@
|
||||
.chat-controls__model {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking-select {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat loading skeleton */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
|
||||
import { setLastActiveSessionKey } from "./app-settings.ts";
|
||||
import { resetToolStream } from "./app-tool-stream.ts";
|
||||
@@ -10,6 +9,7 @@ import { loadModels } from "./controllers/models.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import { normalizeBasePath } from "./navigation.ts";
|
||||
import { parseAgentSessionKey } from "./session-key.ts";
|
||||
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||
@@ -16,8 +15,14 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
|
||||
import { parseAgentSessionKey } from "./session-key.ts";
|
||||
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
import {
|
||||
listThinkingLevelLabels,
|
||||
normalizeThinkLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "./thinking.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
@@ -133,11 +138,16 @@ function renderCronFilterIcon(hiddenCount: number) {
|
||||
export function renderChatSessionSelect(state: AppViewState) {
|
||||
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
|
||||
const modelSelect = renderChatModelSelect(state);
|
||||
const thinkingSelect = renderChatThinkingSelect(state);
|
||||
const selectedSessionLabel =
|
||||
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
|
||||
?.label ?? state.sessionKey;
|
||||
return html`
|
||||
<div class="chat-controls__session-row">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
title=${selectedSessionLabel}
|
||||
?disabled=${!state.connected || sessionGroups.length === 0}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
@@ -162,7 +172,7 @@ export function renderChatSessionSelect(state: AppViewState) {
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${modelSelect}
|
||||
${modelSelect} ${thinkingSelect}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -436,6 +446,7 @@ export function renderChatMobileToggle(state: AppViewState) {
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${renderChatThinkingSelect(state)}
|
||||
<div class="chat-controls__thinking">
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
|
||||
@@ -532,11 +543,16 @@ function renderChatModelSelect(state: AppViewState) {
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled =
|
||||
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__model">
|
||||
<select
|
||||
data-chat-model-select="true"
|
||||
aria-label="Chat model"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
@@ -557,6 +573,125 @@ function renderChatModelSelect(state: AppViewState) {
|
||||
`;
|
||||
}
|
||||
|
||||
type ChatThinkingSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChatThinkingSelectState = {
|
||||
currentOverride: string;
|
||||
defaultLabel: string;
|
||||
options: ChatThinkingSelectOption[];
|
||||
};
|
||||
|
||||
function resolveThinkingTargetModel(state: AppViewState): {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
} {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
return {
|
||||
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
|
||||
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThinkingOptions(
|
||||
provider: string | null,
|
||||
model: string | null,
|
||||
currentOverride: string,
|
||||
): ChatThinkingSelectOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ChatThinkingSelectOption[] = [];
|
||||
|
||||
const addOption = (value: string, label?: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
value: trimmed,
|
||||
label:
|
||||
label ??
|
||||
trimmed
|
||||
.split(/[-_]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
});
|
||||
};
|
||||
|
||||
for (const label of listThinkingLevelLabels(provider)) {
|
||||
const normalized = normalizeThinkLevel(label) ?? label.trim().toLowerCase();
|
||||
addOption(normalized);
|
||||
}
|
||||
if (currentOverride) {
|
||||
addOption(currentOverride);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
const persisted = activeRow?.thinkingLevel;
|
||||
const currentOverride =
|
||||
typeof persisted === "string" && persisted.trim()
|
||||
? (normalizeThinkLevel(persisted) ?? persisted.trim())
|
||||
: "";
|
||||
const { provider, model } = resolveThinkingTargetModel(state);
|
||||
const defaultLevel =
|
||||
provider && model
|
||||
? resolveThinkingDefaultForModel({
|
||||
provider,
|
||||
model,
|
||||
catalog: state.chatModelCatalog ?? [],
|
||||
})
|
||||
: "off";
|
||||
return {
|
||||
currentOverride,
|
||||
defaultLabel: `Default (${defaultLevel})`,
|
||||
options: buildThinkingOptions(provider, model, currentOverride),
|
||||
};
|
||||
}
|
||||
|
||||
function renderChatThinkingSelect(state: AppViewState) {
|
||||
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
|
||||
const busy =
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled = !state.connected || busy || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__thinking-select">
|
||||
<select
|
||||
data-chat-thinking-select="true"
|
||||
aria-label="Chat thinking level"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
await switchChatThinkingLevel(state, next);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
|
||||
${repeat(
|
||||
options,
|
||||
(entry) => entry.value,
|
||||
(entry) =>
|
||||
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
async function switchChatModel(state: AppViewState, nextModel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
@@ -587,6 +722,60 @@ async function switchChatModel(state: AppViewState, nextModel: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function patchSessionThinkingLevel(
|
||||
state: AppViewState,
|
||||
sessionKey: string,
|
||||
thinkingLevel: string | undefined,
|
||||
) {
|
||||
const current = state.sessionsResult;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
state.sessionsResult = {
|
||||
...current,
|
||||
sessions: current.sessions.map((row) =>
|
||||
row.key === sessionKey
|
||||
? {
|
||||
...row,
|
||||
thinkingLevel,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const targetSessionKey = state.sessionKey;
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
|
||||
const previousThinkingLevel = activeRow?.thinkingLevel;
|
||||
const normalizedNext =
|
||||
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
|
||||
const normalizedPrev =
|
||||
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
|
||||
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
|
||||
: undefined;
|
||||
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
|
||||
return;
|
||||
}
|
||||
state.lastError = null;
|
||||
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
|
||||
state.chatThinkingLevel = normalizedNext ?? null;
|
||||
try {
|
||||
await state.client.request("sessions.patch", {
|
||||
key: targetSessionKey,
|
||||
thinkingLevel: normalizedNext ?? null,
|
||||
});
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
|
||||
state.chatThinkingLevel = normalizedPrev ?? null;
|
||||
state.lastError = `Failed to set thinking level: ${String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Channel display labels ────────────────────────────── */
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
bluebubbles: "iMessage",
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { html, nothing } from "lit";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../../src/routing/session-key.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.ts";
|
||||
@@ -96,10 +91,15 @@ import {
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
} from "./controllers/skills.ts";
|
||||
import "./components/dashboard-header.ts";
|
||||
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
|
||||
import "./components/dashboard-header.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "./session-key.ts";
|
||||
import { agentLogoUrl } from "./views/agents-utils.ts";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
|
||||
import {
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
@@ -71,6 +70,7 @@ import type {
|
||||
} from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import { resolveAgentIdFromSessionKey } from "./session-key.ts";
|
||||
import { loadSettings, type UiSettings } from "./storage.ts";
|
||||
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||
import type {
|
||||
|
||||
@@ -3,20 +3,19 @@
|
||||
* Calls gateway RPC methods and returns formatted results.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "../../../../src/auto-reply/thinking.shared.js";
|
||||
import { createChatModelOverride, resolvePreferredServerChatModel } from "../chat-model-ref.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
isSubagentSessionKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../../../../src/routing/session-key.js";
|
||||
import { createChatModelOverride, resolvePreferredServerChatModel } from "../chat-model-ref.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
} from "../session-key.ts";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
normalizeThinkLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "../thinking.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ChatModelOverride,
|
||||
@@ -56,6 +55,24 @@ export type SlashCommandContext = {
|
||||
modelCatalog?: ModelCatalogEntry[];
|
||||
sessionsResult?: SessionsListResult | null;
|
||||
};
|
||||
|
||||
function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["full", "all", "everything"].includes(key)) {
|
||||
return "full";
|
||||
}
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) {
|
||||
return "on";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(
|
||||
client: GatewayBrowserClient,
|
||||
sessionKey: string,
|
||||
@@ -205,7 +222,7 @@ async function executeThink(
|
||||
return {
|
||||
content: formatDirectiveOptions(
|
||||
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
|
||||
formatThinkingLevels(session?.modelProvider, session?.model),
|
||||
formatThinkingLevels(session?.modelProvider),
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -218,7 +235,7 @@ async function executeThink(
|
||||
try {
|
||||
const session = await loadCurrentSession(client, sessionKey);
|
||||
return {
|
||||
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
|
||||
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider)}.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { content: `Failed to validate thinking level: ${String(err)}` };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { resolveAgentIdFromSessionKey } from "../../../../src/routing/session-key.js";
|
||||
import {
|
||||
resolveChatModelOverride,
|
||||
resolvePreferredServerChatModelValue,
|
||||
} from "../chat-model-ref.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import { resolveAgentIdFromSessionKey } from "../session-key.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ChatModelOverride,
|
||||
|
||||
80
ui/src/ui/session-key.ts
Normal file
80
ui/src/ui/session-key.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type ParsedAgentSessionKey = {
|
||||
agentId: string;
|
||||
rest: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AGENT_ID = "main";
|
||||
export const DEFAULT_MAIN_KEY = "main";
|
||||
|
||||
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
|
||||
const LEADING_DASH_RE = /^-+/;
|
||||
const TRAILING_DASH_RE = /-+$/;
|
||||
|
||||
export function parseAgentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): ParsedAgentSessionKey | null {
|
||||
const raw = (sessionKey ?? "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3 || parts[0] !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const agentId = parts[1]?.trim();
|
||||
const rest = parts.slice(2).join(":");
|
||||
if (!agentId || !rest) {
|
||||
return null;
|
||||
}
|
||||
return { agentId, rest };
|
||||
}
|
||||
|
||||
export function normalizeMainKey(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY;
|
||||
}
|
||||
|
||||
export function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AGENT_ID;
|
||||
}
|
||||
if (VALID_ID_RE.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentMainSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
}): string {
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const mainKey = normalizeMainKey(params.mainKey);
|
||||
return `agent:${agentId}:${mainKey}`;
|
||||
}
|
||||
|
||||
export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
if (raw.toLowerCase().startsWith("subagent:")) {
|
||||
return true;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
||||
}
|
||||
93
ui/src/ui/thinking.ts
Normal file
93
ui/src/ui/thinking.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type ThinkingCatalogEntry = {
|
||||
provider: string;
|
||||
id: string;
|
||||
reasoning?: boolean;
|
||||
};
|
||||
|
||||
const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "adaptive"] as const;
|
||||
const BINARY_THINKING_LEVELS = ["off", "on"] as const;
|
||||
const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
|
||||
export function normalizeThinkingProviderId(provider?: string | null): string {
|
||||
if (!provider) {
|
||||
return "";
|
||||
}
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||
return "zai";
|
||||
}
|
||||
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||
return "amazon-bedrock";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isBinaryThinkingProvider(provider?: string | null): boolean {
|
||||
return normalizeThinkingProviderId(provider) === "zai";
|
||||
}
|
||||
|
||||
export function normalizeThinkLevel(raw?: string | null): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.trim().toLowerCase();
|
||||
const collapsed = key.replace(/[\s_-]+/g, "");
|
||||
if (collapsed === "adaptive" || collapsed === "auto") {
|
||||
return "adaptive";
|
||||
}
|
||||
if (collapsed === "xhigh" || collapsed === "extrahigh") {
|
||||
return "xhigh";
|
||||
}
|
||||
if (key === "off") {
|
||||
return "off";
|
||||
}
|
||||
if (["on", "enable", "enabled"].includes(key)) {
|
||||
return "low";
|
||||
}
|
||||
if (["min", "minimal"].includes(key)) {
|
||||
return "minimal";
|
||||
}
|
||||
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) {
|
||||
return "low";
|
||||
}
|
||||
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) {
|
||||
return "medium";
|
||||
}
|
||||
if (
|
||||
["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key)
|
||||
) {
|
||||
return "high";
|
||||
}
|
||||
if (key === "think") {
|
||||
return "minimal";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function listThinkingLevelLabels(provider?: string | null): readonly string[] {
|
||||
return isBinaryThinkingProvider(provider) ? BINARY_THINKING_LEVELS : BASE_THINKING_LEVELS;
|
||||
}
|
||||
|
||||
export function formatThinkingLevels(provider?: string | null): string {
|
||||
return listThinkingLevelLabels(provider).join(", ");
|
||||
}
|
||||
|
||||
export function resolveThinkingDefaultForModel(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
catalog?: ThinkingCatalogEntry[];
|
||||
}): string {
|
||||
const normalizedProvider = normalizeThinkingProviderId(params.provider);
|
||||
const modelId = params.model.trim();
|
||||
if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) {
|
||||
return "adaptive";
|
||||
}
|
||||
if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) {
|
||||
return "adaptive";
|
||||
}
|
||||
const candidate = params.catalog?.find(
|
||||
(entry) => entry.provider === params.provider && entry.id === params.model,
|
||||
);
|
||||
return candidate?.reasoning ? "low" : "off";
|
||||
}
|
||||
@@ -61,17 +61,23 @@ function createChatHeaderState(
|
||||
overrides: {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
models?: ModelCatalogEntry[];
|
||||
omitSessionFromList?: boolean;
|
||||
} = {},
|
||||
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
|
||||
let currentModel = overrides.model ?? null;
|
||||
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
|
||||
let currentThinkingLevel = overrides.thinkingLevel ?? null;
|
||||
const omitSessionFromList = overrides.omitSessionFromList ?? false;
|
||||
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.patch") {
|
||||
const nextModel = (params.model as string | null | undefined) ?? null;
|
||||
const nextThinkingLevel = params.thinkingLevel as string | null | undefined;
|
||||
if ("thinkingLevel" in params) {
|
||||
currentThinkingLevel = nextThinkingLevel ?? null;
|
||||
}
|
||||
if (!nextModel) {
|
||||
currentModel = null;
|
||||
currentModelProvider = null;
|
||||
@@ -97,11 +103,15 @@ function createChatHeaderState(
|
||||
return { messages: [], thinkingLevel: null };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return createSessionsListResult({
|
||||
const result = createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
if (result.sessions[0]) {
|
||||
result.sessions[0].thinkingLevel = currentThinkingLevel ?? undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
@@ -119,11 +129,17 @@ function createChatHeaderState(
|
||||
sessionKey: "main",
|
||||
connected: true,
|
||||
sessionsHideCron: true,
|
||||
sessionsResult: createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
}),
|
||||
sessionsResult: (() => {
|
||||
const result = createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
if (result.sessions[0]) {
|
||||
result.sessions[0].thinkingLevel = currentThinkingLevel ?? undefined;
|
||||
}
|
||||
return result;
|
||||
})(),
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: catalog,
|
||||
chatModelsLoading: false,
|
||||
@@ -940,6 +956,72 @@ describe("chat view", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the default thinking level in the chat header picker", async () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: "gpt-5",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const thinkingSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-thinking-select="true"]',
|
||||
);
|
||||
expect(thinkingSelect).not.toBeNull();
|
||||
expect(thinkingSelect?.value).toBe("");
|
||||
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Default (off)");
|
||||
});
|
||||
|
||||
it("patches the current session thinking level from the chat header picker", async () => {
|
||||
const { state, request } = createChatHeaderState({
|
||||
model: "gpt-5",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const thinkingSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-thinking-select="true"]',
|
||||
);
|
||||
expect(thinkingSelect).not.toBeNull();
|
||||
|
||||
thinkingSelect!.value = "off";
|
||||
thinkingSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
thinkingLevel: "off",
|
||||
});
|
||||
expect(state.sessionsResult?.sessions[0]?.thinkingLevel).toBe("off");
|
||||
});
|
||||
|
||||
it("clears the session thinking override back to the default thinking level", async () => {
|
||||
const { state, request } = createChatHeaderState({
|
||||
model: "gpt-5",
|
||||
modelProvider: "openai",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const thinkingSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-thinking-select="true"]',
|
||||
);
|
||||
expect(thinkingSelect).not.toBeNull();
|
||||
expect(thinkingSelect?.value).toBe("high");
|
||||
|
||||
thinkingSelect!.value = "";
|
||||
thinkingSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
thinkingLevel: null,
|
||||
});
|
||||
expect(state.sessionsResult?.sessions[0]?.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
Reference in New Issue
Block a user