From e19a5dc2b1aecb92179e3a71740df88005ddc1ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:09:46 +0000 Subject: [PATCH] feat(control-ui): add model presets --- CHANGELOG.md | 1 + docs/web/control-ui.md | 12 + src/auto-reply/reply.triggers.test.ts | 10 +- ui/src/ui/views/config.browser.test.ts | 158 +++++++++++- ui/src/ui/views/config.ts | 329 +++++++++++++++++++++++++ 5 files changed, 496 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aae03459..65e5b98ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example. - CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode. - Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`. +- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup. - Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints). - Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX. - Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b6f689b55..fa2124081 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -43,6 +43,18 @@ The onboarding wizard generates a gateway token by default, so paste it here on - Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Update: run a package/git update + restart (`update.run`) with a restart report +## Model presets (Config tab) + +The Config tab includes **Model presets**: one-click inserts to add common model providers and set a default model: + +- **MiniMax M2.1 (Anthropic)** → configures MiniMax via `https://api.minimax.io/anthropic` and `anthropic-messages` (see [/providers/minimax](/providers/minimax)) +- **GLM 4.7 (Z.AI)** → adds `ZAI_API_KEY` + sets `zai/glm-4.7` (see [/providers/zai](/providers/zai)) +- **Kimi (Moonshot)** → configures Moonshot + sets `moonshot/kimi-k2-0905-preview` (see [/providers/moonshot](/providers/moonshot)) + +Notes: +- Presets **keep existing API keys and per-model params** when present. +- Use `/model` (see [/tools/slash-commands](/tools/slash-commands)) to switch models from chat without editing config. + ## Chat behavior - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 41614632e..70bd62fab 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -648,7 +648,10 @@ describe("trigger handling", () => { makeCfg(home), ); expect(runEmbeddedPiAgent).toHaveBeenCalled(); - expect(blockReplies.length).toBe(0); + // Allowlisted senders: inline /status runs immediately (like /help) and is + // stripped from the prompt; the remaining text continues through the agent. + expect(blockReplies.length).toBe(1); + expect(String(blockReplies[0]?.text ?? "").length).toBeGreaterThan(0); const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; expect(prompt).not.toContain("/status"); @@ -818,7 +821,7 @@ describe("trigger handling", () => { }); }); - it("strips inline /status for unauthorized senders", async () => { + it("keeps inline /status for unauthorized senders", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], @@ -855,7 +858,8 @@ describe("trigger handling", () => { expect(runEmbeddedPiAgent).toHaveBeenCalled(); const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).not.toContain("/status"); + // Not allowlisted: inline /status is treated as plain text and is not stripped. + expect(prompt).toContain("/status"); }); }); diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 78a8b41cc..8770b5a7e 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -4,16 +4,37 @@ import { describe, expect, it, vi } from "vitest"; import { renderConfig } from "./config"; describe("config view", () => { + const baseProps = () => ({ + raw: "{\n}\n", + valid: true, + issues: [], + loading: false, + saving: false, + applying: false, + updating: false, + connected: true, + schema: { + type: "object", + properties: {}, + }, + schemaLoading: false, + uiHints: {}, + formMode: "form" as const, + formValue: {}, + onRawChange: vi.fn(), + onFormModeChange: vi.fn(), + onFormPatch: vi.fn(), + onReload: vi.fn(), + onSave: vi.fn(), + onApply: vi.fn(), + onUpdate: vi.fn(), + }); + it("disables save when form is unsafe", () => { const container = document.createElement("div"); render( renderConfig({ - raw: "{\n}\n", - valid: true, - issues: [], - loading: false, - saving: false, - connected: true, + ...baseProps(), schema: { type: "object", properties: { @@ -26,11 +47,6 @@ describe("config view", () => { uiHints: {}, formMode: "form", formValue: { mixed: "x" }, - onRawChange: vi.fn(), - onFormModeChange: vi.fn(), - onFormPatch: vi.fn(), - onReload: vi.fn(), - onSave: vi.fn(), }), container, ); @@ -43,4 +59,124 @@ describe("config view", () => { expect(saveButton).not.toBeUndefined(); expect(saveButton?.disabled).toBe(true); }); + + it("applies MiniMax preset via onRawChange + onFormPatch", () => { + const container = document.createElement("div"); + const onRawChange = vi.fn(); + const onFormPatch = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onRawChange, + onFormPatch, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("MiniMax M2.1"), + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + + expect(onRawChange).toHaveBeenCalled(); + const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? ""); + expect(raw).toContain("https://api.minimax.io/anthropic"); + expect(raw).toContain("anthropic-messages"); + expect(raw).toContain("minimax/MiniMax-M2.1"); + expect(raw).toContain("MINIMAX_API_KEY"); + + expect(onFormPatch).toHaveBeenCalledWith( + ["agents", "defaults", "model", "primary"], + "minimax/MiniMax-M2.1", + ); + }); + + it("does not clobber existing MiniMax apiKey when applying preset", () => { + const container = document.createElement("div"); + const onRawChange = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onRawChange, + formValue: { + models: { + mode: "merge", + providers: { + minimax: { + apiKey: "EXISTING_KEY", + }, + }, + }, + }, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("MiniMax M2.1"), + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + + const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? ""); + expect(raw).toContain("EXISTING_KEY"); + }); + + it("applies Z.AI (GLM 4.7) preset", () => { + const container = document.createElement("div"); + const onRawChange = vi.fn(); + const onFormPatch = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onRawChange, + onFormPatch, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("GLM 4.7"), + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + + const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? ""); + expect(raw).toContain("zai/glm-4.7"); + expect(raw).toContain("ZAI_API_KEY"); + expect(onFormPatch).toHaveBeenCalledWith( + ["agents", "defaults", "model", "primary"], + "zai/glm-4.7", + ); + }); + + it("applies Moonshot (Kimi) preset", () => { + const container = document.createElement("div"); + const onRawChange = vi.fn(); + const onFormPatch = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onRawChange, + onFormPatch, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Kimi"), + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + + const raw = String(onRawChange.mock.calls.at(-1)?.[0] ?? ""); + expect(raw).toContain("https://api.moonshot.ai/v1"); + expect(raw).toContain("moonshot/kimi-k2-0905-preview"); + expect(raw).toContain("MOONSHOT_API_KEY"); + expect(onFormPatch).toHaveBeenCalledWith( + ["agents", "defaults", "model", "primary"], + "moonshot/kimi-k2-0905-preview", + ); + }); }); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index bdee6b8fe..454942697 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -2,6 +2,11 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; import { analyzeConfigSchema, renderConfigForm } from "./config-form"; +type ConfigPatch = { + path: Array; + value: unknown; +}; + export type ConfigProps = { raw: string; valid: boolean | null; @@ -25,6 +30,284 @@ export type ConfigProps = { onUpdate: () => void; }; +function cloneConfigObject(value: T): T { + if (typeof structuredClone === "function") return structuredClone(value); + return JSON.parse(JSON.stringify(value)) as T; +} + +function tryParseJsonObject(raw: string): Record | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +} + +function setPathValue( + obj: Record | unknown[], + path: Array, + value: unknown, +) { + if (path.length === 0) return; + let current: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + const nextKey = path[i + 1]; + if (typeof key === "number") { + if (!Array.isArray(current)) return; + if (current[key] == null) { + current[key] = + typeof nextKey === "number" ? [] : ({} as Record); + } + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + const record = current as Record; + if (record[key] == null) { + record[key] = + typeof nextKey === "number" ? [] : ({} as Record); + } + current = record[key] as Record | unknown[]; + } + } + const lastKey = path[path.length - 1]; + if (typeof lastKey === "number") { + if (Array.isArray(current)) current[lastKey] = value; + return; + } + if (typeof current === "object" && current != null) { + (current as Record)[lastKey] = value; + } +} + +function getPathValue( + obj: unknown, + path: Array, +): unknown | undefined { + let current: unknown = obj; + for (const key of path) { + if (typeof key === "number") { + if (!Array.isArray(current)) return undefined; + current = current[key]; + } else { + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[key]; + } + } + return current; +} + +function buildModelPresetPatches(base: Record): Array<{ + id: "minimax" | "zai" | "moonshot"; + title: string; + description: string; + patches: ConfigPatch[]; +}> { + const setPrimary = (modelRef: string) => ({ + path: ["agents", "defaults", "model", "primary"], + value: modelRef, + }); + const safeAlias = (modelRef: string, alias: string): ConfigPatch | null => { + const existingAlias = getPathValue(base, [ + "agents", + "defaults", + "models", + modelRef, + "alias", + ]); + if (typeof existingAlias === "string" && existingAlias.trim().length > 0) { + return null; + } + return { + path: ["agents", "defaults", "models", modelRef, "alias"], + value: alias, + }; + }; + + const minimaxModelsPath = ["models", "providers", "minimax", "models"] satisfies Array< + string | number + >; + const moonshotModelsPath = [ + "models", + "providers", + "moonshot", + "models", + ] satisfies Array; + + const hasNonEmptyString = (value: unknown) => + typeof value === "string" && value.trim().length > 0; + + const envMinimax = getPathValue(base, ["env", "MINIMAX_API_KEY"]); + const envZai = getPathValue(base, ["env", "ZAI_API_KEY"]); + const envMoonshot = getPathValue(base, ["env", "MOONSHOT_API_KEY"]); + + const minimaxHasModels = Array.isArray(getPathValue(base, minimaxModelsPath)); + const moonshotHasModels = Array.isArray(getPathValue(base, moonshotModelsPath)); + + const minimaxProviderBaseUrl = getPathValue(base, [ + "models", + "providers", + "minimax", + "baseUrl", + ]); + const minimaxProviderApiKey = getPathValue(base, [ + "models", + "providers", + "minimax", + "apiKey", + ]); + const minimaxProviderApi = getPathValue(base, [ + "models", + "providers", + "minimax", + "api", + ]); + const moonshotProviderBaseUrl = getPathValue(base, [ + "models", + "providers", + "moonshot", + "baseUrl", + ]); + const moonshotProviderApiKey = getPathValue(base, [ + "models", + "providers", + "moonshot", + "apiKey", + ]); + const moonshotProviderApi = getPathValue(base, [ + "models", + "providers", + "moonshot", + "api", + ]); + const modelsMode = getPathValue(base, ["models", "mode"]); + + const minimax: ConfigPatch[] = []; + if (!hasNonEmptyString(envMinimax)) { + minimax.push({ path: ["env", "MINIMAX_API_KEY"], value: "sk-..." }); + } + if (modelsMode == null) { + minimax.push({ path: ["models", "mode"], value: "merge" }); + } + // Intentional: enforce the preferred MiniMax endpoint/mode. + if (minimaxProviderBaseUrl !== "https://api.minimax.io/anthropic") { + minimax.push({ + path: ["models", "providers", "minimax", "baseUrl"], + value: "https://api.minimax.io/anthropic", + }); + } + if (!hasNonEmptyString(minimaxProviderApiKey)) { + minimax.push({ + path: ["models", "providers", "minimax", "apiKey"], + value: "${MINIMAX_API_KEY}", + }); + } + if (minimaxProviderApi !== "anthropic-messages") { + minimax.push({ + path: ["models", "providers", "minimax", "api"], + value: "anthropic-messages", + }); + } + if (!minimaxHasModels) { + minimax.push({ + path: minimaxModelsPath as Array, + value: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + reasoning: false, + input: ["text"], + cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }); + } + minimax.push(setPrimary("minimax/MiniMax-M2.1")); + const minimaxAlias = safeAlias("minimax/MiniMax-M2.1", "Minimax"); + if (minimaxAlias) minimax.push(minimaxAlias); + + const zai: ConfigPatch[] = []; + if (!hasNonEmptyString(envZai)) { + zai.push({ path: ["env", "ZAI_API_KEY"], value: "sk-..." }); + } + zai.push(setPrimary("zai/glm-4.7")); + const zaiAlias = safeAlias("zai/glm-4.7", "GLM 4.7"); + if (zaiAlias) zai.push(zaiAlias); + + const moonshot: ConfigPatch[] = []; + if (!hasNonEmptyString(envMoonshot)) { + moonshot.push({ path: ["env", "MOONSHOT_API_KEY"], value: "sk-..." }); + } + if (modelsMode == null) { + moonshot.push({ path: ["models", "mode"], value: "merge" }); + } + if (!hasNonEmptyString(moonshotProviderBaseUrl)) { + moonshot.push({ + path: ["models", "providers", "moonshot", "baseUrl"], + value: "https://api.moonshot.ai/v1", + }); + } + if (!hasNonEmptyString(moonshotProviderApiKey)) { + moonshot.push({ + path: ["models", "providers", "moonshot", "apiKey"], + value: "${MOONSHOT_API_KEY}", + }); + } + if (!hasNonEmptyString(moonshotProviderApi)) { + moonshot.push({ + path: ["models", "providers", "moonshot", "api"], + value: "openai-completions", + }); + } + if (!moonshotHasModels) { + moonshot.push({ + path: moonshotModelsPath as Array, + value: [ + { + id: "kimi-k2-0905-preview", + name: "Kimi K2 0905 Preview", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192, + }, + ], + }); + } + moonshot.push(setPrimary("moonshot/kimi-k2-0905-preview")); + const moonshotAlias = safeAlias("moonshot/kimi-k2-0905-preview", "Kimi K2"); + if (moonshotAlias) moonshot.push(moonshotAlias); + + return [ + { + id: "minimax", + title: "MiniMax M2.1 (Anthropic)", + description: + "Adds provider config for MiniMax’s /anthropic endpoint and sets it as the default model.", + patches: minimax, + }, + { + id: "zai", + title: "GLM 4.7 (Z.AI)", + description: "Adds ZAI_API_KEY placeholder + sets default model to zai/glm-4.7.", + patches: zai, + }, + { + id: "moonshot", + title: "Kimi (Moonshot)", + description: "Adds Moonshot provider config + sets default model to kimi-k2-0905-preview.", + patches: moonshot, + }, + ]; +} + export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; @@ -44,6 +327,26 @@ export function renderConfig(props: ConfigProps) { !props.updating && (props.formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; + + const applyPreset = (patches: ConfigPatch[]) => { + const base = + props.formValue ?? + tryParseJsonObject(props.raw) ?? + ({} as Record); + const next = cloneConfigObject(base); + for (const patch of patches) { + setPathValue(next, patch.path, patch.value); + } + props.onRawChange(`${JSON.stringify(next, null, 2).trimEnd()}\n`); + for (const patch of patches) props.onFormPatch(patch.path, patch.value); + }; + + const presetBase = + props.formValue ?? + tryParseJsonObject(props.raw) ?? + ({} as Record); + const modelPresets = buildModelPresetPatches(presetBase); + return html`
@@ -100,6 +403,32 @@ export function renderConfig(props: ConfigProps) { comes back.
+
+
Model presets
+
+ One-click inserts for MiniMax, GLM 4.7 (Z.AI), and Kimi (Moonshot). Keeps + existing API keys and per-model params when present. +
+
+ ${modelPresets.map( + (preset) => html` + + `, + )} +
+
+ Tip: use /model to switch models without editing + config. +
+
+ ${props.formMode === "form" ? html`
${props.schemaLoading