From 67c5370ee7b037531902d3dfcfc7447be88cf84e Mon Sep 17 00:00:00 2001 From: KrasimirKralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Mon, 4 May 2026 21:20:01 +0300 Subject: [PATCH 1/4] feat(chat): account-tier independence, dynamic catalog, redesigned header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account-tier resolution decoupled from active chat provider * /setup-api/ai-models/status resolves clawaiAccountTier (any clawai profile present) separately from clawaiTier (active chat provider). Drives ClawKeep + Remote Desktop entitlement so a Max user chatting via OpenAI keeps paid features unlocked. * use-clawbox-login reads account-level tier with fallback to active-provider tier for older /status responses. * Shelf shield colour, shelf click target, and the "Free backup" notification all bind to clawboxLogin.loggedIn (account-level) instead of provider-equality. User-name field * Removed placeholder text; 5s poll of ui_user_name preference gated by userNameEditedRef so local typing isn't clobbered. * Translations: deleted settings.userName.placeholder from all 10 locales (parity test forbids empty values, removal beats blanking). * CLAWBOX.md + config/clawbox-workspace-guide.md teach the agent to call preferences_set("ui_user_name", "") when offered a name. Disk-backed catalog cache + background warmup * /setup-api/ai-models/catalog serves from data/catalog-cache/ .json with 6h TTL. Refreshes happen out-of-band — the route never waits on the openclaw bin (~3min CPU on Jetson). * Boot warmup fires off refreshes for every CATALOG_PROVIDERS entry on first import, staggered 5s. Subsequent picker opens are instant. * Static fallbacks updated to mirror current upstream catalogs so the warming-state UX is decent on fresh installs. Per-provider reasoning effort levels * Replaced the universal 9-level dropdown with REASONING_BY_PROVIDER table sourced from each upstream's API docs: OpenAI / Codex Off / Low / Medium / High / X-High (Medium) Anthropic Low / Medium / High / Max (High) Google Off / Low / Medium / High / Adaptive (Adaptive) DeepSeek Low / Medium / High (High) ClawBox AI Low / Medium / High (High) OpenRouter Off / Minimal / Low / Medium / High / X-High (Medium) * Per-provider localStorage remembers each provider's last choice. Curated openai / openai-codex catalogs * ALLOWED_MODEL_RE_BY_PROVIDER filters the live openclaw catalog to gpt-5.4 + gpt-5.5 family only. Older gens (4.1, 5.0-5.3) hidden. * openai-codex additionally hides -pro variants (ChatGPT-account auth rejects them with "model not supported when using Codex with a ChatGPT account"). Redesigned chat header dropdowns * New HeaderDropdown component replaces native for the chat + * header pills so we can keep the trigger compact (truncating with + * "...") while the open menu shows full labels + hints in a wider + * card that floats above the chat content. */ +.header-dropdown-trigger { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #fff; + padding: 5px 24px 5px 12px; + font-size: 11px; + font-weight: 600; + outline: none; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + display: inline-flex; + align-items: center; + position: relative; + min-width: 0; + border-radius: 999px; +} +.header-dropdown-trigger:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.09); + border-color: rgba(255, 255, 255, 0.16); +} +.header-dropdown-trigger:focus-visible { + outline: 2px solid rgba(249, 115, 22, 0.6); + outline-offset: -2px; +} +.header-dropdown-trigger:disabled { + cursor: default; + opacity: 0.55; +} +.header-dropdown-trigger-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.header-dropdown-trigger-chevron { + position: absolute; + right: 4px; + font-size: 16px; + color: rgba(255, 255, 255, 0.55); + transition: transform 0.15s ease; + pointer-events: none; +} + +.header-dropdown-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 100; + background: #1a1f2e; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + padding: 4px; + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; + /* Lock-in pop animation. */ + animation: header-dropdown-pop 0.12s ease-out; +} +@keyframes header-dropdown-pop { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.header-dropdown-option { + appearance: none; + -webkit-appearance: none; + background: transparent; + border: none; + color: #fff; + text-align: left; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + transition: background 0.12s ease; +} +.header-dropdown-option:hover:not(:disabled) { + background: rgba(249, 115, 22, 0.12); +} +.header-dropdown-option:focus-visible { + outline: 2px solid rgba(249, 115, 22, 0.6); + outline-offset: -2px; +} +.header-dropdown-option:disabled { + cursor: default; + opacity: 0.4; +} +.header-dropdown-option.is-active { + background: rgba(249, 115, 22, 0.18); +} +.header-dropdown-option.is-active:hover { + background: rgba(249, 115, 22, 0.24); +} +.header-dropdown-option-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-weight: 600; +} +.header-dropdown-option-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.header-dropdown-option-check { + font-size: 16px; + color: #f97316; + flex-shrink: 0; +} +.header-dropdown-option-hint { + font-size: 11px; + color: rgba(255, 255, 255, 0.55); + font-weight: 400; + white-space: normal; + line-height: 1.3; +} + /* Card surface — matching openclawhardware.dev dark sections */ .card-surface { background: var(--surface-card); diff --git a/src/app/page.tsx b/src/app/page.tsx index 66bd88a45..38a79dad8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -169,33 +169,45 @@ function InstalledAppIcon({ iconUrl, appId, name, size = "w-6 h-6" }: { iconUrl? return extension; } -function isClawAiProvider(provider: unknown): boolean { - if (typeof provider !== "string") return false; - const normalized = provider.trim().toLowerCase(); - return normalized === "clawai" || normalized === "deepseek"; -} - function ChromeDesktopInner() { const { t } = useT(); const resolveAppName = (app: AppDef) => t(app.name) || app.name; const [setupChecked, setSetupChecked] = useState(false); const [setupRequired, setSetupRequired] = useState(false); const [showClawAiOfferNotification, setShowClawAiOfferNotification] = useState(false); - const [clawAiAuthenticated, setClawAiAuthenticated] = useState(false); - // Live tier (Free → null, Pro → "flash", Max → "pro"). Drives the - // ClawKeep shield colour and any other paid-only desktop affordance. + // Account-level "is ClawBox AI configured on this device?" — drives + // the shelf shield (colour + click target) and the offer-notification + // visibility. Sourced from useClawboxLogin (which now polls + // /setup-api/ai-models/status and exposes `clawaiConfigured` via its + // `loggedIn` field), not from /setup-api/setup/status's + // `ai_model_provider` — that's the *active chat provider* and would + // falsely flip to false the moment a Max subscriber switches the + // chat header dropdown to OpenAI, leaving them with a red shield + // that opens AI Settings instead of ClawKeep. const clawboxLogin = useClawboxLogin(); + const clawAiAuthenticated = clawboxLogin.loggedIn; const clawkeepEntitled = clawboxLogin.tier !== null; const syncSetupStatus = useCallback(async () => { const data = await fetch("/setup-api/setup/status").then((r) => r.json()); setSetupRequired(!data.setup_complete); - const hasClawAi = isClawAiProvider(data.ai_model_provider) && !!data.ai_model_configured; - setClawAiAuthenticated(hasClawAi); - setShowClawAiOfferNotification(!!data.setup_complete && !hasClawAi); return data; }, []); + // The "Free backup with ClawBox AI" offer-notification asks the user + // to add ClawBox AI as a *desktop backup provider* — that's an + // account-level question, not "is clawai the active chat provider + // right now?". Source it from useClawboxLogin so a Max subscriber + // chatting via OpenAI doesn't get nagged to add an account they + // already have. Only show after setup is complete, the hook has + // settled (avoid a transient pop on refresh), and no clawai + // profile is configured. + useEffect(() => { + if (setupRequired) { setShowClawAiOfferNotification(false); return; } + if (clawboxLogin.loading) return; + setShowClawAiOfferNotification(!clawboxLogin.loggedIn); + }, [setupRequired, clawboxLogin.loading, clawboxLogin.loggedIn]); + // One-shot cleanup of stale chat localStorage from older builds. useEffect(() => { purgeLegacyChatCaches() }, []); diff --git a/src/app/setup-api/ai-models/catalog/route.ts b/src/app/setup-api/ai-models/catalog/route.ts index 92d09b332..82846e407 100644 --- a/src/app/setup-api/ai-models/catalog/route.ts +++ b/src/app/setup-api/ai-models/catalog/route.ts @@ -1,50 +1,45 @@ import { NextRequest, NextResponse } from "next/server"; import { spawn } from "child_process"; +import { promises as fsp } from "fs"; +import path from "path"; import { findOpenclawBin } from "@/lib/openclaw-config"; +import { DATA_DIR } from "@/lib/config-store"; import { CATALOG_PROVIDERS, isCatalogProvider } from "@/lib/provider-models"; export const dynamic = "force-dynamic"; // /setup-api/ai-models/catalog?provider= // -// Single source of truth for the AI-provider model dropdowns. -// Replaces the hand-curated arrays that used to live in -// src/lib/openrouter-models.ts and src/lib/provider-models.ts and rotted -// every time an upstream rename/deprecation shipped. +// Async-first model catalog. The route never blocks on openclaw — that +// CLI takes ~3 minutes to enumerate models on the Jetson, far longer +// than any reasonable HTTP timeout. Instead: // -// Strategy: -// * For every provider OpenClaw has a built-in catalog for (anthropic, -// openai, openai-codex, google, …), we shell out to -// `openclaw models list --provider

--all --json`. That's the same -// list the gateway will accept at chat time, so a user-pickable model -// from the dropdown is by construction routeable. -// * For OpenRouter we hit OpenRouter's own /api/v1/models endpoint -// directly. OpenClaw's `models list --provider openrouter` only -// surfaces models that are already configured locally (~2 entries on -// a fresh device), which is useless for picker UX. The live OpenRouter -// catalog has 340+ models and we trim/sort for popularity ourselves. +// * Disk cache at data/catalog-cache/.json is the source of +// truth across restarts. Reads are O(1) file IO. +// * Background refreshes spawn `openclaw models list --provider

+// --all --json` (or fetch OpenRouter's REST endpoint) detached from +// any request, write the result to the disk cache on success. +// * On boot, the first import of this module kicks off a refresh for +// every CATALOG_PROVIDERS entry so picker opens are instant from +// minute 4 onward. +// * If both caches are empty (fresh install, first picker open), we +// return an empty payload with `warming: true`. The client then +// falls back to the static catalog in src/lib/provider-models.ts. // -// Both paths cache for `CACHE_TTL_MS` so reopening the picker doesn't -// re-fork openclaw or re-fetch from the network. Errors are *not* -// cached — a transient failure shouldn't stick a 500 in front of the -// user for the next 10 minutes. +// Force-refresh via `?refresh=1` triggers a refresh in the background +// and serves whatever's currently cached — the user never waits. const OPENCLAW_BIN = findOpenclawBin(); -const COMMAND_TIMEOUT_MS = 15_000; -const CACHE_TTL_MS = 5 * 60_000; +const REFRESH_TIMEOUT_MS = 5 * 60_000; // openclaw on Jetson is ~3min +const REFRESH_INTERVAL_MS = 6 * 60 * 60_000; // 6h +const CACHE_DIR = path.join(DATA_DIR, "catalog-cache"); const OPENROUTER_API = "https://openrouter.ai/api/v1/models"; interface CatalogModel { - /** Provider-native id (no `/` prefix). For openrouter this - * keeps the `/` shape since that *is* the openrouter slug. */ id: string; - /** Friendly display name from the upstream catalog. */ label: string; - /** Optional short hint for the dropdown row. */ hint?: string; - /** Context window in tokens (0 if unknown). Used for sorting / display. */ contextWindow: number; - /** Modalities (e.g. "text+image"). Optional, surfaced as a small badge. */ input?: string; } @@ -52,39 +47,50 @@ interface CatalogResponse { provider: string; models: CatalogModel[]; defaultModelId: string; - /** Whether the user may type a model id outside this list. */ allowCustom: boolean; - /** ms since-epoch when this list was fetched (cache visibility for the UI). */ fetchedAt: number; + /** Set by GET when the cached payload is older than REFRESH_INTERVAL_MS. */ + stale?: boolean; + /** Set when neither cache has anything yet — client falls back to static catalog. */ + warming?: boolean; } -interface CacheEntry { - expiresAt: number; - payload: CatalogResponse; -} - -// Map from provider id → most recent successful payload. -const cache = new Map(); -// In-flight builds keyed by provider so a thundering herd of concurrent -// cold-start requests (wizard + chat popup + status poll all mounting at -// once) collapses to a single child-process spawn / OpenRouter fetch. -// Cleared on settle. -const inFlight = new Map>(); +// Process-local hot cache. Survives request boundaries within a single +// node process; lost on restart (disk cache covers that). +const memCache = new Map(); +// Single-flight guard so two concurrent requests don't both fork openclaw. +const refreshing = new Set(); -// Defaults applied when the upstream catalog doesn't ship a "preferred" -// signal. Picked to match the previous static-array defaults so existing -// auto-fill logic (configure route, chat-header summary) stays stable -// when the catalog is empty / network is down. Keys must be a subset of -// CATALOG_PROVIDERS — adding a provider there means seeding a default -// here too. const DEFAULT_MODEL_BY_PROVIDER: Record = { + clawai: "deepseek-v4-flash", anthropic: "claude-sonnet-4-6", - openai: "gpt-5", + openai: "gpt-5.4", "openai-codex": "gpt-5.4", google: "gemini-2.5-flash", openrouter: "anthropic/claude-haiku-4.5", }; +// ClawBox AI catalog is hardcoded — Mike's gateway routes via DeepSeek +// upstream but the only end-user-pickable variants are the two device +// tiers (Flash + Pro), gated by subscription. Skipping the openclaw +// spawn for clawai also dodges the 3-min CLI execution time on Jetson. +const CLAWAI_STATIC_MODELS: CatalogModel[] = [ + { + id: "deepseek-v4-flash", + label: "Pro Tier", + contextWindow: 128_000, + input: "text+image", + hint: "Default. Faster, lower cost.", + }, + { + id: "deepseek-v4-pro", + label: "Max Tier", + contextWindow: 128_000, + input: "text+image", + hint: "1.6T frontier model. Max plan only.", + }, +]; + function noStore() { return { "Cache-Control": "no-store" } as const; } @@ -93,36 +99,30 @@ function fail(message: string, status: number): NextResponse { return NextResponse.json({ error: message }, { status, headers: noStore() }); } -function runOpenclawJson(args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(OPENCLAW_BIN, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, HOME: process.env.HOME ?? "/home/clawbox" }, - }); - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - try { child.kill("SIGKILL"); } catch { /* already gone */ } - }, COMMAND_TIMEOUT_MS); - child.stdout.on("data", (b: Buffer) => { stdout += b.toString("utf8"); }); - child.stderr.on("data", (b: Buffer) => { stderr += b.toString("utf8"); }); - child.on("error", (e) => { - clearTimeout(timer); - reject(new Error(`openclaw spawn failed: ${e.message}`)); - }); - child.on("close", (code) => { - clearTimeout(timer); - if (code !== 0) { - reject(new Error(`openclaw exited ${code}: ${stderr.slice(-300).trim()}`)); - return; - } - try { - resolve(JSON.parse(stdout) as T); - } catch { - reject(new Error(`openclaw produced non-JSON output: ${stdout.slice(0, 200)}`)); - } - }); - }); +async function readDiskCache(provider: string): Promise { + try { + const file = path.join(CACHE_DIR, `${provider}.json`); + const raw = await fsp.readFile(file, "utf8"); + const parsed = JSON.parse(raw) as CatalogResponse; + if (!Array.isArray(parsed.models) || typeof parsed.fetchedAt !== "number") return null; + return parsed; + } catch { + return null; + } +} + +async function writeDiskCache(provider: string, payload: CatalogResponse): Promise { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + const file = path.join(CACHE_DIR, `${provider}.json`); + const tmp = `${file}.tmp`; + // Write-then-rename so a crash mid-write can't leave a half-JSON + // file that breaks the next read. + await fsp.writeFile(tmp, JSON.stringify(payload), "utf8"); + await fsp.rename(tmp, file); + } catch (e) { + console.error(`[catalog] disk write failed for ${provider}:`, e instanceof Error ? e.message : e); + } } interface OpenclawListResponse { @@ -137,15 +137,47 @@ interface OpenclawListResponse { }>; } -async function fetchOpenclawCatalog(provider: string): Promise { - // `--all` returns the full provider catalog (not just locally-configured - // entries). Filter out anything tagged "deprecated" upstream so the UI - // picker doesn't suggest models that 400 at chat time. - const data = await runOpenclawJson([ - "models", "list", "--provider", provider, "--all", "--json", - ]); +interface OpenRouterListResponse { + data: Array<{ + id: string; + name?: string; + description?: string; + context_length?: number; + architecture?: { input_modalities?: string[] }; + deprecated?: boolean; + }>; +} + +// Per-provider allowlist regex. When set, only model ids matching the +// pattern survive the catalog filter. Used to curate noisy upstream +// catalogs down to a useful set without the picker exploding to 40+ +// entries the user has to scroll past. +// +// openai (API-key auth): all 5.4 + 5.5 SKUs including -pro variants. +// Pros require an API key and DO work on the api.openai.com path. +// +// openai-codex (ChatGPT-account auth): 5.4, 5.4-mini, 5.5 only — NO +// -pro variants. Per developers.openai.com/codex/models, the Pro +// models are API-key-only and the Codex/ChatGPT-account auth path +// 400s with "model not supported when using Codex with a ChatGPT +// account" if you try gpt-5.4-pro or gpt-5.5-pro. +// +// Older generations (4.1, 5.0, 5.1, 5.2, 5.3) are intentionally +// excluded per user request. New generations matching the pattern +// (e.g. a future gpt-5.6) will auto-appear; new families (gpt-6) will +// require updating the regex. +const ALLOWED_MODEL_RE_BY_PROVIDER: Record = { + openai: /^gpt-5\.[45](-pro|-mini)?$/, + "openai-codex": /^gpt-5\.[45](-mini)?$/, +}; + +function transformOpenclawEntries( + provider: string, + entries: OpenclawListResponse["models"], +): CatalogModel[] { + const allowed = ALLOWED_MODEL_RE_BY_PROVIDER[provider]; const out: CatalogModel[] = []; - for (const entry of data.models ?? []) { + for (const entry of entries) { if (typeof entry.key !== "string") continue; const idPrefix = `${provider}/`; const id = entry.key.startsWith(idPrefix) @@ -153,6 +185,7 @@ async function fetchOpenclawCatalog(provider: string): Promise { : entry.key; if (!id) continue; if (entry.tags?.includes("deprecated")) continue; + if (allowed && !allowed.test(id)) continue; out.push({ id, label: typeof entry.name === "string" && entry.name.trim() ? entry.name : id, @@ -160,10 +193,6 @@ async function fetchOpenclawCatalog(provider: string): Promise { input: typeof entry.input === "string" ? entry.input : undefined, }); } - // Newest-first ordering: bigger context generally means newer model on - // every catalog we ship today (claude 200k+, gpt-5 400k, gemini 1M). - // Fall back to alpha when contextWindow is unknown / equal so the list - // stays stable on re-fetch. out.sort((a, b) => { if (a.contextWindow !== b.contextWindow) return b.contextWindow - a.contextWindow; return a.label.localeCompare(b.label); @@ -171,29 +200,9 @@ async function fetchOpenclawCatalog(provider: string): Promise { return out; } -interface OpenRouterListResponse { - data: Array<{ - id: string; - name?: string; - description?: string; - context_length?: number; - architecture?: { input_modalities?: string[] }; - /** OpenRouter sometimes flags retired models with this hint. */ - deprecated?: boolean; - }>; -} - -async function fetchOpenRouterCatalog(): Promise { - const res = await fetch(OPENROUTER_API, { - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(8000), - }); - if (!res.ok) { - throw new Error(`openrouter ${res.status}`); - } - const data = (await res.json()) as OpenRouterListResponse; +function transformOpenRouterEntries(entries: OpenRouterListResponse["data"]): CatalogModel[] { const out: CatalogModel[] = []; - for (const entry of data.data ?? []) { + for (const entry of entries) { if (typeof entry.id !== "string" || !entry.id) continue; if (entry.deprecated) continue; const inputs = entry.architecture?.input_modalities ?? []; @@ -213,20 +222,12 @@ async function fetchOpenRouterCatalog(): Promise { return out; } -async function buildCatalog(provider: string): Promise { - const models = provider === "openrouter" - ? await fetchOpenRouterCatalog() - : await fetchOpenclawCatalog(provider); - - // The "default" picked here just seeds the picker on first load — - // user clicks override it. Prefer the historic default if it still - // exists in the live catalog; otherwise use the first entry. +function buildPayload(provider: string, models: CatalogModel[]): CatalogResponse { const fallbackDefault = DEFAULT_MODEL_BY_PROVIDER[provider]; const defaultModelId = models.find((m) => m.id === fallbackDefault)?.id ?? models[0]?.id ?? fallbackDefault ?? ""; - return { provider, models, @@ -236,6 +237,123 @@ async function buildCatalog(provider: string): Promise { }; } +function fetchOpenclawCatalog(provider: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(OPENCLAW_BIN, ["models", "list", "--provider", provider, "--all", "--json"], { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: process.env.HOME ?? "/home/clawbox", + }, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + try { child.kill("SIGKILL"); } catch { /* already gone */ } + fn(); + }; + const timer = setTimeout(() => { + finish(() => reject(new Error(`openclaw timed out after ${REFRESH_TIMEOUT_MS}ms`))); + }, REFRESH_TIMEOUT_MS); + child.stdout.on("data", (b: Buffer) => { + stdout += b.toString("utf8"); + // openclaw's compile-cache wrapper keeps a grandchild holding our + // stdout pipe open for ~3 minutes after the JSON arrives. Parse + // on each chunk so we resolve as soon as the JSON is syntactically + // complete instead of waiting for `close`. + if (settled || !stdout.includes("}")) return; + try { + const parsed = JSON.parse(stdout) as OpenclawListResponse; + clearTimeout(timer); + finish(() => resolve(transformOpenclawEntries(provider, parsed.models ?? []))); + } catch { + // Partial JSON — keep accumulating. + } + }); + child.stderr.on("data", (b: Buffer) => { stderr += b.toString("utf8"); }); + child.on("error", (e) => { + clearTimeout(timer); + finish(() => reject(new Error(`openclaw spawn failed: ${e.message}`))); + }); + child.on("close", (code: number | null) => { + clearTimeout(timer); + if (settled) return; + if (code !== 0) { + finish(() => reject(new Error(`openclaw exited ${code}: ${stderr.slice(-300).trim()}`))); + return; + } + finish(() => { + try { + const parsed = JSON.parse(stdout) as OpenclawListResponse; + resolve(transformOpenclawEntries(provider, parsed.models ?? [])); + } catch { + reject(new Error(`openclaw produced non-JSON output: ${stdout.slice(0, 200)}`)); + } + }); + }); + }); +} + +async function fetchOpenRouterCatalog(): Promise { + const res = await fetch(OPENROUTER_API, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) { + throw new Error(`openrouter ${res.status}`); + } + const data = (await res.json()) as OpenRouterListResponse; + return transformOpenRouterEntries(data.data ?? []); +} + +// Refresh the catalog for `provider` in the background. Returns +// immediately; the actual openclaw spawn / openrouter fetch runs out +// of band. Single-flight via `refreshing` so concurrent requests +// collapse to one fork. +function refreshInBackground(provider: string): void { + if (refreshing.has(provider)) return; + refreshing.add(provider); + + const fetcher: Promise = provider === "openrouter" + ? fetchOpenRouterCatalog() + : provider === "clawai" + ? Promise.resolve(CLAWAI_STATIC_MODELS) + : fetchOpenclawCatalog(provider); + + fetcher + .then(async (models) => { + const payload = buildPayload(provider, models); + memCache.set(provider, payload); + await writeDiskCache(provider, payload); + console.log(`[catalog] refreshed ${provider}: ${models.length} models`); + }) + .catch((err: unknown) => { + console.error(`[catalog] refresh failed for ${provider}:`, err instanceof Error ? err.message : err); + }) + .finally(() => { + refreshing.delete(provider); + }); +} + +// Boot warmup: when this module is first imported (typically when the +// user opens the AI picker for the first time post-restart), fire off +// a background refresh for every provider so subsequent picker opens +// are instant. Idempotent — guarded by `bootWarmupStarted`. +let bootWarmupStarted = false; +function bootWarmup(): void { + if (bootWarmupStarted) return; + bootWarmupStarted = true; + // Stagger by 5s so we don't fork four openclaw bins at the exact + // same instant. Each one is ~2 cores of CPU for ~3 minutes. + for (let i = 0; i < CATALOG_PROVIDERS.length; i++) { + const p = CATALOG_PROVIDERS[i]; + setTimeout(() => refreshInBackground(p), i * 5_000); + } +} + export async function GET(req: NextRequest) { const provider = req.nextUrl.searchParams.get("provider")?.trim().toLowerCase() ?? ""; if (!provider) { @@ -244,37 +362,40 @@ export async function GET(req: NextRequest) { if (!isCatalogProvider(provider)) { return fail(`Unknown provider: ${provider}. Supported: ${CATALOG_PROVIDERS.join(", ")}`, 400); } - // `?refresh=1` skips the cache — useful from the configure route after - // a save, when we want the freshly-saved provider's catalog reflected - // in the next picker open without waiting for the TTL. const force = req.nextUrl.searchParams.get("refresh") === "1"; - const cached = cache.get(provider); - if (!force && cached && cached.expiresAt > Date.now()) { - return NextResponse.json(cached.payload, { headers: noStore() }); - } + bootWarmup(); - try { - let inflight = inFlight.get(provider); - if (!inflight) { - inflight = buildCatalog(provider).finally(() => { - inFlight.delete(provider); - }); - inFlight.set(provider, inflight); + // Hot path: in-memory cache. + let cached = memCache.get(provider); + if (!cached) { + const fromDisk = await readDiskCache(provider); + if (fromDisk) { + memCache.set(provider, fromDisk); + cached = fromDisk; } - const payload = await inflight; - cache.set(provider, { expiresAt: Date.now() + CACHE_TTL_MS, payload }); + } + + const ageMs = cached ? Date.now() - cached.fetchedAt : Infinity; + const isStale = ageMs > REFRESH_INTERVAL_MS; + if (force || isStale || !cached) { + refreshInBackground(provider); + } + + if (cached) { + const payload: CatalogResponse = isStale ? { ...cached, stale: true } : cached; return NextResponse.json(payload, { headers: noStore() }); - } catch (err) { - // Fall back to the last good cached payload if any — better to show - // a slightly stale list than to break the picker on a transient - // network blip / openclaw spawn failure. - if (cached) { - return NextResponse.json( - { ...cached.payload, stale: true, error: err instanceof Error ? err.message : "fetch failed" }, - { headers: noStore() }, - ); - } - return fail(err instanceof Error ? err.message : "Catalog fetch failed", 502); } + + // No cache anywhere yet. The client picker falls back to the static + // catalog in src/lib/provider-models.ts when models[] is empty. + const empty: CatalogResponse = { + provider, + models: [], + defaultModelId: DEFAULT_MODEL_BY_PROVIDER[provider] ?? "", + allowCustom: true, + fetchedAt: 0, + warming: true, + }; + return NextResponse.json(empty, { headers: noStore() }); } diff --git a/src/app/setup-api/ai-models/status/route.ts b/src/app/setup-api/ai-models/status/route.ts index 6c7d8ebd8..da62c7ec9 100644 --- a/src/app/setup-api/ai-models/status/route.ts +++ b/src/app/setup-api/ai-models/status/route.ts @@ -214,34 +214,57 @@ export async function GET() { } const normalizedProvider = normalizeProvider(provider); - let clawaiTier: ClawboxAiTier | null = null; - let tierSource: "portal" | "picker" = "picker"; - if (normalizedProvider === "clawai") { - // Default to the wizard's picker selection; the portal call below - // overwrites both fields when it gets a definitive answer. - // Falling back to the picker on transient portal outages keeps - // the badge from blinking off during network blips. - const localTier = normalizeClawboxAiTier( - await getConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY).catch(() => null), - ); - clawaiTier = localTier; - // Treat the local picker selection as a ceiling: if the user never - // authorised a paid tier locally (localTier === null), the badge - // stays hidden regardless of the portal stamp. Skip the portal - // call entirely in that case — saves a 4 s timeout on the render - // path during cold-cache fetches and avoids the portal-upgrade - // bug where a Free user gets deviceTier="flash" stamped. Drop - // this guard once the portal gates deviceTier by subscription. - const token = config.models?.providers?.deepseek?.apiKey; - if (localTier !== null && typeof token === "string" && token.startsWith("claw_")) { - const lookup = await fetchPortalTier(token); + // ClawBox AI account entitlement is independent of which provider is + // currently driving the chat. A Max subscriber chatting via OpenAI + // still has the paid plan that unlocks ClawKeep + Remote Desktop — + // resolving the tier off the active profile alone (the old + // behaviour) falsely blocks them. + // + // Walk every profile for any clawai/deepseek entry, look up the + // stored claw_ token's tier on the portal, and surface that as + // `clawaiAccountTier`. The badge-facing `clawaiTier` field stays + // tied to the active chat provider so the chat-header badge keeps + // its current behaviour (no badge when chatting via OpenAI). + const localTier = normalizeClawboxAiTier( + await getConfigValue(CLAWBOX_AI_TIER_CONFIG_KEY).catch(() => null), + ); + const clawaiTokenCandidate = config.models?.providers?.deepseek?.apiKey; + const clawaiToken = typeof clawaiTokenCandidate === "string" && clawaiTokenCandidate.startsWith("claw_") + ? clawaiTokenCandidate + : null; + const hasClawaiProfile = profileKeys.some((key) => { + const entry = profiles[key]; + const entryProvider = normalizeProvider(entry?.provider ?? key.split(":")[0]); + return entryProvider === "clawai"; + }); + + let clawaiAccountTier: ClawboxAiTier | null = null; + let accountTierSource: "portal" | "picker" = "picker"; + if (hasClawaiProfile) { + clawaiAccountTier = localTier; + // Defence-in-depth: if the user never authorised a paid tier + // locally (localTier === null), keep the account tier null + // regardless of what the portal stamps. Skips the portal call + // entirely on the Free path — saves a 4 s cold-cache timeout + // on the render-path GET and avoids the portal-upgrade bug + // where a Free user gets deviceTier="flash" issued. + if (localTier !== null && clawaiToken) { + const lookup = await fetchPortalTier(clawaiToken); if (lookup.source === "portal") { - clawaiTier = lookup.tier; - tierSource = "portal"; + clawaiAccountTier = lookup.tier; + accountTierSource = "portal"; } } } + // The badge-facing tier mirrors the account tier *only* when + // ClawBox AI is the active chat provider. Switching to OpenAI in + // the chat dropdown should hide the chat-header tier badge — the + // user isn't currently chatting with ClawBox AI — without + // demoting their account-level entitlement. + const clawaiTier = normalizedProvider === "clawai" ? clawaiAccountTier : null; + const tierSource = normalizedProvider === "clawai" ? accountTierSource : "picker"; + return NextResponse.json({ connected: !!normalizedProvider, provider: normalizedProvider, @@ -249,6 +272,13 @@ export async function GET() { mode, model, clawaiTier, + clawaiAccountTier, + // Whether *any* clawai profile is configured. Distinguishes + // "no ClawBox AI account at all" (false) from "Free user with + // a paired clawai token" (true, clawaiAccountTier=null) — the + // hook needs this to gate ClawKeep / Remote Desktop sign-in + // prompts independently of paid-tier checks. + clawaiConfigured: hasClawaiProfile, tierSource, }, { headers: { @@ -257,7 +287,7 @@ export async function GET() { }); } catch { return NextResponse.json( - { connected: false, provider: null, providerLabel: null, mode: null, model: null, clawaiTier: null, tierSource: "picker" }, + { connected: false, provider: null, providerLabel: null, mode: null, model: null, clawaiTier: null, clawaiAccountTier: null, clawaiConfigured: false, tierSource: "picker" }, { headers: { "Cache-Control": "no-store", diff --git a/src/app/setup-api/chat/model/route.ts b/src/app/setup-api/chat/model/route.ts index 9eff421c9..cf006b1dd 100644 --- a/src/app/setup-api/chat/model/route.ts +++ b/src/app/setup-api/chat/model/route.ts @@ -137,31 +137,25 @@ async function loadChatModelState() { } const primaryProvider = normalizeProvider(configStore.ai_model_provider); - // Keyed by *model id* (e.g. "deepseek/deepseek-v4-pro") rather than - // by provider so a single provider can surface multiple options — - // e.g. ClawBox AI Flash vs Pro both belong to the `deepseek` - // provider but are independent rows in the chat dropdown. + // Keyed by *provider* (not by model id) so each provider gets ONE + // row in the chat dropdown. Model variants (ClawBox AI Flash/Pro, + // Claude Haiku/Sonnet/Opus, GPT-5.4 / -mini, etc.) are surfaced via + // the secondary model picker in ChatPopup, not as independent rows. + // The first call wins — subsequent rememberPrimaryOption calls for + // a provider that already has an entry are no-ops, with the active + // model taking priority via the call order in loadChatModelState. const configuredPrimaryOptions = new Map(); const rememberPrimaryOption = ( model: string | null | undefined, providerHint?: string | null, - labelOverride?: string | null, ) => { const trimmedModel = typeof model === "string" ? model.trim() : ""; if (!trimmedModel || isLocalModel(trimmedModel)) return; const provider = normalizeProvider(providerHint ?? normalizeProviderFromModel(trimmedModel)); if (!provider) return; - const trimmedLabelOverride = labelOverride?.trim(); - const existing = configuredPrimaryOptions.get(trimmedModel); - // Allow callers that know the canonical model name (provider - // definition rows) to upgrade a placeholder option that an earlier - // pass had to fall back on labelForProvider for. Without this the - // first entry from `activeModel`/`primaryModel` would lock the - // dropdown row to the bare "ClawBox AI" label even after the - // provider definition told us it's actually "ClawBox AI Pro". - if (existing && !trimmedLabelOverride) return; - const label = trimmedLabelOverride || labelForProvider(provider, "AI Provider"); - configuredPrimaryOptions.set(trimmedModel, { + if (configuredPrimaryOptions.has(provider)) return; + const label = labelForProvider(provider, "AI Provider"); + configuredPrimaryOptions.set(provider, { id: trimmedModel, label, model: trimmedModel, @@ -182,24 +176,23 @@ async function loadChatModelState() { const provider = normalizeProvider(rawProvider); if (!provider || provider === "ollama" || provider === "llamacpp") continue; - // Prefer enumerating every model registered under this provider's - // `models.providers..models` block so multi-model - // providers (ClawBox AI Flash + Pro, future tier expansions, etc.) - // surface every variant in the chat dropdown. Fall back to the - // single hard-coded default when the provider definition has no - // explicit models list. + // Pick which model represents this provider in the dropdown: + // 1. activeModel if it belongs to this provider (so the row's + // "model" field matches what the gateway is actually using). + // 2. The first model in the openclaw provider definition. + // 3. The hard-coded default for this provider. const providerDef = providerDefinitions[rawProvider]; const definedModels = (providerDef?.models ?? []).filter((m): m is { id: string; name?: string } => typeof m?.id === "string" && m.id.trim().length > 0); - if (definedModels.length > 0) { - for (const def of definedModels) { - const fullyQualified = `${rawProvider}/${def.id}`; - rememberPrimaryOption(fullyQualified, rawProvider, def.name); - } - continue; + let model: string | null = null; + if (activeModel && normalizeProviderFromModel(activeModel) === provider) { + model = activeModel; + } else if (definedModels.length > 0) { + model = `${rawProvider}/${definedModels[0].id}`; + } else { + model = defaultModelForProvider(rawProvider); } - const model = defaultModelForProvider(rawProvider); if (model) rememberPrimaryOption(model, rawProvider); } @@ -320,8 +313,14 @@ export async function POST(request: Request) { if (!parsed || !isValidModelId(parsed.provider, parsed.modelId)) { return NextResponse.json({ error: "Invalid model identifier" }, { status: 400 }); } + // Normalize the parsed provider so the deepseek/clawai alias + // comparison works — option.provider was set via normalizeProvider + // (deepseek → clawai), so a raw `parsed.provider === "deepseek"` + // would never match a `"clawai"` option even though they refer + // to the same auth profile. + const parsedProviderNormalized = normalizeProvider(parsed.provider); const providerConfigured = state.options.some( - (option) => option.provider === parsed.provider && option.available, + (option) => option.provider === parsedProviderNormalized && option.available, ); if (!providerConfigured) { return NextResponse.json({ error: "Selected AI provider is not configured" }, { status: 400 }); diff --git a/src/components/ChatPopup.tsx b/src/components/ChatPopup.tsx index 399c13050..04d2abc1f 100644 --- a/src/components/ChatPopup.tsx +++ b/src/components/ChatPopup.tsx @@ -92,6 +92,30 @@ function getChatModelOptionText(option: ChatModelState['options'][number]) { return option.label || option.id } +// Compact provider labels for the chat header pill. The chat panel +// can be docked at ~370px wide where "OpenAI Codex" + "GPT-5.4 Mini" +// + "Medium" combined exceeds the available width and pills truncate +// to "OpenAI Co...". Drop the brand prefix on the provider pill so +// users see the distinctive part ("Codex" vs "GPT" — both still +// clearly OpenAI) without overflow. Settings page and notification +// messages still use the full PROVIDER_LABELS values from the chat +// model route. +const PROVIDER_PILL_LABEL: Record = { + 'ClawBox AI': 'ClawBox', + 'Anthropic Claude': 'Claude', + 'OpenAI GPT': 'GPT', + 'OpenAI Codex': 'Codex', + 'Google Gemini': 'Gemini', + 'OpenRouter': 'OpenRouter', + 'Ollama Local': 'Ollama', + 'Gemma 4 Local': 'Gemma 4', +} +function getProviderPillText(option: ChatModelState['options'][number]): string { + const full = getChatModelOptionText(option) + if (!option.available) return full + return PROVIDER_PILL_LABEL[option.label ?? ''] ?? full +} + import { renderText } from '@/lib/chat-markdown' import { useT } from '@/lib/i18n' import { @@ -101,6 +125,7 @@ import { useProviderCatalog } from '@/hooks/useProviderCatalog' import { useClawboxLogin } from '@/lib/use-clawbox-login' import { isClawboxAiProModel } from '@/lib/clawbox-ai-models' import { PORTAL_DASHBOARD_URL } from '@/lib/max-subscription' +import { HeaderDropdown } from '@/components/HeaderDropdown' // Strip gateway wrapper tags like , , etc. function stripGatewayTags(text: string): string { @@ -134,16 +159,12 @@ function extractText(msg: unknown): string { const DEFAULT_SIZE = { w: 400, h: 500 } const DEFAULT_PANEL_WIDTH = DEFAULT_SIZE.w -// Mirrors OpenClaw's canonical thinking-level set -// (`openclaw/dist/thinking-BW_4_Ip1.js: BASE_THINKING_LEVELS` + xhigh/max/ -// adaptive). 'default' is a UI-only sentinel meaning "don't override — -// let the gateway use its configured default"; we map it to undefined on -// the wire. Anything outside this set is silently coerced so a stale or -// hand-edited localStorage value can't reach the gateway. -type ThinkingLevel = 'default' | 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'adaptive' -const THINKING_LEVELS: readonly ThinkingLevel[] = ['default', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'adaptive'] +// Reasoning effort levels accepted by the OpenClaw gateway. The wire +// vocabulary is broader than what any single upstream API supports — +// each provider only honors a subset, with the gateway translating +// (e.g. DeepSeek `xhigh`→`max`, Google `adaptive`→`thinking_budget=-1`). +type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'adaptive' const THINKING_LEVEL_LABELS: Record = { - default: 'Default', off: 'Off', minimal: 'Minimal', low: 'Low', @@ -153,24 +174,64 @@ const THINKING_LEVEL_LABELS: Record = { max: 'Max', adaptive: 'Adaptive', } -function normalizeThinkingLevel(value: string | null | undefined): ThinkingLevel { - return THINKING_LEVELS.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : 'default' + +// Per-provider effort levels and defaults. Sourced from each upstream's +// official API docs (see fix/clawai-account-tier branch history for the +// research notes). Showing the universal 8-level dropdown for every +// provider was misleading — `Max` doesn't exist on OpenAI, `Minimal` +// was dropped from gpt-5.4+, Google has `Adaptive` (thinking_budget=-1) +// where others have `Default`, etc. Per-provider config keeps the UI +// honest. +interface ProviderReasoningConfig { + levels: readonly ThinkingLevel[] + default: ThinkingLevel } -// DeepSeek V4's stack only differentiates three real states (disabled / high -// reasoning / max reasoning), but the OpenClaw gateway exposes them as -// off / high / xhigh — `max` is never appended to a model's allowed-level -// list (thinking.ts:188,201), so sessions.patch rejects it. The translation -// layer in provider-stream-shared.ts maps OpenClaw 'xhigh' → DeepSeek's -// reasoning_effort: "max" upstream, so users still get the strongest -// reasoning when they pick the "X-High" option here. We relabel 'high' as -// "Default" because that's DeepSeek's effective out-of-the-box behavior. -const DEEPSEEK_THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'high', 'xhigh'] -const DEEPSEEK_THINKING_LABELS: Partial> = { - high: 'Default', +const REASONING_BY_PROVIDER: Record = { + openai: { levels: ['off', 'low', 'medium', 'high', 'xhigh'], default: 'medium' }, + 'openai-codex': { levels: ['off', 'low', 'medium', 'high', 'xhigh'], default: 'medium' }, + // Anthropic effort docs: `low | medium | high | max` on Opus 4.6+, + // Sonnet 4.6, Opus 4.7, Mythos. Default per platform.claude.com is + // `high`. xhigh is Opus-4.7-only; we omit until we add per-model + // gating. + anthropic: { levels: ['low', 'medium', 'high', 'max'], default: 'high' }, + // Gemini 2.5 thinking_budget: 0=off (Flash/Lite only — Pro silently + // ignores), -1=adaptive (auto). Picker stays provider-wide; Pro will + // fall back to adaptive when user picks Off. + google: { levels: ['off', 'low', 'medium', 'high', 'adaptive'], default: 'adaptive' }, + // DeepSeek V4 docs accept low/medium/high/xhigh/max but compatibility + // layer maps low+medium→high and xhigh→max upstream, so the user-facing + // useful scale is just three. + deepseek: { levels: ['low', 'medium', 'high'], default: 'high' }, + // ClawBox AI routes via DeepSeek today. + clawai: { levels: ['low', 'medium', 'high'], default: 'high' }, + // OpenRouter normalizes per underlying model — surface the full set + // they document at openrouter.ai/docs/guides/best-practices/reasoning-tokens. + openrouter: { levels: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'], default: 'medium' }, } -function isDeepSeekV4Model(model: string | null | undefined): boolean { - return typeof model === 'string' && /deepseek-v4/i.test(model) + +const FALLBACK_REASONING_CONFIG: ProviderReasoningConfig = { + levels: ['off', 'low', 'medium', 'high'], + default: 'medium', +} + +function getProviderReasoningConfig(provider: string | null | undefined): ProviderReasoningConfig { + if (!provider) return FALLBACK_REASONING_CONFIG + return REASONING_BY_PROVIDER[provider] ?? FALLBACK_REASONING_CONFIG +} + +const PERSIST_KEY_PREFIX = 'clawbox:chat:thinkingLevel' + +function readPersistedThinkingLevel( + provider: string | null | undefined, + cfg: ProviderReasoningConfig, +): ThinkingLevel { + if (typeof window === 'undefined' || !provider) return cfg.default + try { + const raw = window.localStorage?.getItem(`${PERSIST_KEY_PREFIX}:${provider}`) + if (raw && cfg.levels.includes(raw as ThinkingLevel)) return raw as ThinkingLevel + } catch { /* localStorage unavailable */ } + return cfg.default } function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThinkingChange, onPanelModeChange, initialPanelWidth, mascotX, mobile = false, trayMode = false }: ChatPopupProps) { @@ -191,10 +252,11 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink const [errorMsg, setErrorMsg] = useState('') const [chatModelState, setChatModelState] = useState(null) const [switchingModel, setSwitchingModel] = useState(false) - const [thinkingLevel, setThinkingLevel] = useState(() => { - if (typeof window === 'undefined') return 'high' - return normalizeThinkingLevel(window.localStorage?.getItem('clawbox:chat:thinkingLevel')) - }) + // Initialised to a generic placeholder; the real value is snapped to + // the active provider's persisted choice (or that provider's default) + // by the [headerProvider] effect below as soon as chatModelState + // resolves. + const [thinkingLevel, setThinkingLevel] = useState('high') const fileInputRef = useRef(null) const [attachments, setAttachments] = useState<{ name: string; path: string; type: string }[]>([]) @@ -219,29 +281,23 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink ) return activeOption?.provider ?? null }, [chatModelState]) - const isDeepSeekV4Active = useMemo(() => { - if (!chatModelState) return false - if (isDeepSeekV4Model(chatModelState.activeModel)) return true - const activeOption = chatModelState.options.find( - (option) => option.id === chatModelState.activeOptionId, - ) - return isDeepSeekV4Model(activeOption?.model) - }, [chatModelState]) - const visibleThinkingLevels = isDeepSeekV4Active ? DEEPSEEK_THINKING_LEVELS : THINKING_LEVELS + const reasoningConfig = useMemo( + () => getProviderReasoningConfig(headerProvider), + [headerProvider], + ) + const visibleThinkingLevels = reasoningConfig.levels const labelForThinkingLevel = useCallback((level: ThinkingLevel): string => { - if (isDeepSeekV4Active) { - const override = DEEPSEEK_THINKING_LABELS[level] - if (override) return override - } return THINKING_LEVEL_LABELS[level] ?? level - }, [isDeepSeekV4Active]) - // Snap a stale picker value (e.g. 'low' / 'medium' / 'default') to 'high' - // when DeepSeek is active so display, wire payload, and the picker option - // all agree. Without this, the select would render a value with no matching - //

+ {chatModelState && (() => { + const activeId = chatModelState.activeOptionId ?? chatModelState.options[0]?.id ?? '' + const activeOption = chatModelState.options.find(o => o.id === activeId) + const triggerLabel = activeOption ? getProviderPillText(activeOption) : activeId + return ( + ({ + id: option.id, + label: getChatModelOptionText(option), + }))} + onChange={handleChatSourceChange} onPointerDown={stopHeaderDrag} disabled={switchingModel} - style={{ - appearance: 'none', - WebkitAppearance: 'none', - MozAppearance: 'none', - width: '100%', - background: 'rgba(255,255,255,0.05)', - border: '1px solid rgba(255,255,255,0.08)', - color: '#fff', - borderRadius: 10, - padding: '6px 28px 6px 10px', - fontSize: 11, - fontWeight: 600, - outline: 'none', - cursor: switchingModel ? 'default' : 'pointer', - }} - > - {chatModelState.options.map((option) => ( - - ))} - - -
- )} + triggerMaxWidth={130} + popoverWidth={220} + /> + ) + })()} {(() => { // Inline model switcher: renders next to the provider dropdown // whenever the active provider has multiple available models. @@ -1283,10 +1331,17 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink if (!activeOption?.provider) return null const catalog = chatProviderCatalog if (!catalog || catalog.models.length < 2) return null + // ClawBox AI's wire-format provider is `deepseek` (Mike's + // gateway forwards to DeepSeek's API), while the UI normalizes + // to `clawai`. Try the canonical provider first, then fall + // back to the deepseek alias so the picker can resolve the + // active model id either way. const activeModelId = extractProviderModelId( chatModelState.activeModel, activeOption.provider, - ) + ) ?? (activeOption.provider === 'clawai' + ? extractProviderModelId(chatModelState.activeModel, 'deepseek') + : null) if (!activeModelId) return null const curatedHasActive = catalog.models.some( (option) => option.id === activeModelId, @@ -1298,131 +1353,52 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink ...catalog.models, ] return ( -
({ + id: option.id, + label: option.label, + hint: option.hint, + }))} + onChange={(nextId) => { + if (nextId === activeModelId) return + // Wire-format provider for ClawBox AI is `deepseek` + // (Mike's gateway routes via DeepSeek). Sending + // `clawai/...` would be rejected by the gateway as + // an unknown provider. + const wireProvider = activeOption.provider === 'clawai' + ? 'deepseek' + : activeOption.provider + void switchChatModel({ + model: `${wireProvider}/${nextId}`, + label: nextId, + }) }} - > - - -
+ onPointerDown={stopHeaderDrag} + disabled={switchingModel} + triggerMaxWidth={140} + popoverWidth={240} + /> ) })()} -
({ + id: level, + label: labelForThinkingLevel(level), + }))} + onChange={handleThinkingLevelChange} onPointerDown={stopHeaderDrag} - style={{ - position: 'relative', - display: 'inline-flex', - alignItems: 'center', - maxWidth: 120, - marginLeft: 6, - }} - > - - -
+ triggerMaxWidth={100} + popoverWidth={180} + />
{(status === 'connecting' || switchingModel) && ( diff --git a/src/components/HeaderDropdown.tsx b/src/components/HeaderDropdown.tsx new file mode 100644 index 000000000..a9c3172c8 --- /dev/null +++ b/src/components/HeaderDropdown.tsx @@ -0,0 +1,155 @@ +'use client' + +import type { PointerEvent as ReactPointerEvent } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface HeaderDropdownOption { + id: string + label: string + hint?: string + disabled?: boolean +} + +interface HeaderDropdownProps { + value: string + options: HeaderDropdownOption[] + onChange: (id: string) => void + ariaLabel?: string + /** Optional override for the text rendered inside the closed trigger + * pill. Useful when the popover should show the full label + * ("OpenAI Codex") but the pill itself wants a compact form + * ("Codex") to fit a narrow header. Falls back to the active + * option's `label`. */ + triggerLabel?: string + /** Maximum trigger width before the label truncates with "...". */ + triggerMaxWidth?: number + /** Width of the popover when open. Defaults to a comfortable 220px so + * full model names fit even when the trigger pill is squeezed. */ + popoverWidth?: number + disabled?: boolean + /** Stop pointer events from bubbling to the chat header drag handler. + * Pass through whatever the chat popup uses. */ + onPointerDown?: (event: ReactPointerEvent) => void +} + +/** + * Custom popover dropdown for the chat header pills. Replaces native + * would render a value with no // matching