From dc14d00cc8fb361a6087ba8737fdf38e86268711 Mon Sep 17 00:00:00 2001
From: Will
Date: Mon, 30 Mar 2026 21:04:02 +0100
Subject: [PATCH] openclaw config
---
src/app/api/openclaw-config/route.ts | 47 ++
src/app/page.tsx | 24 +-
src/components/OpenClawSettings.tsx | 782 +++++++++++++++++++++++++++
src/hooks/useOpenClawConfig.ts | 124 +++++
4 files changed, 974 insertions(+), 3 deletions(-)
create mode 100644 src/app/api/openclaw-config/route.ts
create mode 100644 src/components/OpenClawSettings.tsx
create mode 100644 src/hooks/useOpenClawConfig.ts
diff --git a/src/app/api/openclaw-config/route.ts b/src/app/api/openclaw-config/route.ts
new file mode 100644
index 0000000..2ee3419
--- /dev/null
+++ b/src/app/api/openclaw-config/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from "next/server";
+import { promises as fs } from "fs";
+import path from "path";
+import os from "os";
+
+const CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json");
+
+export async function GET() {
+ try {
+ const raw = await fs.readFile(CONFIG_PATH, "utf-8");
+ return NextResponse.json({ ok: true, config: JSON.parse(raw), path: CONFIG_PATH });
+ } catch (err: any) {
+ if (err.code === "ENOENT") {
+ return NextResponse.json({ ok: false, error: "Config file not found", path: CONFIG_PATH }, { status: 404 });
+ }
+ return NextResponse.json({ ok: false, error: String(err) }, { status: 500 });
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const { config } = await req.json();
+ if (!config || typeof config !== "object") {
+ return NextResponse.json({ ok: false, error: "Invalid config payload" }, { status: 400 });
+ }
+ // Create dir if missing
+ await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
+ // Atomic write via temp file
+ const tmp = CONFIG_PATH + ".tmp";
+ await fs.writeFile(tmp, JSON.stringify(config, null, 2), "utf-8");
+ await fs.rename(tmp, CONFIG_PATH);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ ok: false, error: String(err) }, { status: 500 });
+ }
+}
+
+// Validate JSON without saving
+export async function PUT(req: NextRequest) {
+ try {
+ const { raw } = await req.json();
+ JSON.parse(raw); // throws if invalid
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ ok: false, error: String(err) }, { status: 400 });
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9ba4993..ee0743a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,4 +1,4 @@
-// src/app/page.tsx
+/// src/app/page.tsx
"use client";
import {
useState, useRef, useEffect, useCallback, useMemo,
@@ -9,6 +9,9 @@ import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
import { useLiveVoice } from "@/hooks/useLiveVoice";
import { stopSpeaking } from "@/lib/tts";
+import OpenClawSettings from "@/components/OpenClawSettings";
+
+
// ─── Types ─────────────────────────────────────────────────────────────────────
type Tab = "chat" | "history" | "settings";
@@ -179,9 +182,10 @@ export default function Home() {
const [isSpeaking, setIsSpeaking] = useState(false);
const [textInput, setTextInput] = useState("");
const [settingsSection, setSettingsSection] = useState<
- "voice" | "model" | "ui" | "behaviour" | "account" | "storage"
+ "voice" | "model" | "ui" | "behaviour" | "account" | "storage" | "openclaw"
>("voice");
+
const { messages, isLoading, sendMessage } = useChat();
const { status: whisperStatus, transcribe } = useWhisper();
const { isRecording, startRecording, stopRecording } = useVoiceRecorder();
@@ -937,7 +941,8 @@ export default function Home() {
{ id: "behaviour", icon: "⚡", label: "Behaviour" },
{ id: "account", icon: "👤", label: "Profile" },
{ id: "storage", icon: "💾", label: "Storage" },
- ] as { id: typeof settingsSection; icon: string; label: string }[]).map(({ id, icon, label }) => (
+ { id: "openclaw", icon: "🔨", label: "OpenClaw" },
+ ] as { id: typeof settingsSection; icon: string; label: string }[]).map(({ id, icon, label }) => (
@@ -1194,9 +1200,21 @@ export default function Home() {
}}
className="text-[13px] text-red-400/40 hover:text-red-400 transition-colors">
Delete all conversation history
+
)}
+
+
+ {/* ─ OpenClaw ─ */}
+ {settingsSection === "openclaw" && (
+
+
+
+ )}
+
+
+
)}
diff --git a/src/components/OpenClawSettings.tsx b/src/components/OpenClawSettings.tsx
new file mode 100644
index 0000000..e4996d3
--- /dev/null
+++ b/src/components/OpenClawSettings.tsx
@@ -0,0 +1,782 @@
+"use client";
+import { useState } from "react";
+import { useOpenClawConfig, OpenClawConfig, ModelProvider, OllamaModel } from "@/hooks/useOpenClawConfig";
+
+// ─── Data ──────────────────────────────────────────────────────────────────────
+
+const OLLAMA_MODELS = [
+ "ollama/qwen3:8b", "ollama/qwen3:14b", "ollama/qwen3:32b",
+ "ollama/llama3.3:70b", "ollama/llama3.1:8b", "ollama/llama3.1:70b",
+ "ollama/mistral:7b", "ollama/mistral-nemo", "ollama/mixtral:8x7b",
+ "ollama/gemma3:4b", "ollama/gemma3:12b", "ollama/gemma3:27b",
+ "ollama/phi4:14b", "ollama/phi4-mini:3.8b",
+ "ollama/deepseek-r1:7b", "ollama/deepseek-r1:14b", "ollama/deepseek-r1:32b",
+ "ollama/codellama:7b", "ollama/codellama:13b",
+ "ollama/nous-hermes2", "ollama/openchat:7b",
+ "anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-3-5",
+ "openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3", "openai/o4-mini",
+];
+
+const BASE_URLS = [
+ { label: "Ollama (default)", value: "http://127.0.0.1:11434/v1" },
+ { label: "LM Studio", value: "http://127.0.0.1:1234/v1" },
+ { label: "OpenAI", value: "https://api.openai.com/v1" },
+ { label: "Anthropic", value: "https://api.anthropic.com" },
+ { label: "Groq", value: "https://api.groq.com/openai/v1" },
+ { label: "Together AI", value: "https://api.together.xyz/v1" },
+ { label: "OpenRouter", value: "https://openrouter.ai/api/v1" },
+ { label: "Mistral", value: "https://api.mistral.ai/v1" },
+ { label: "Ollama (remote :11434)", value: "http://0.0.0.0:11434/v1" },
+];
+
+const API_TYPES = [
+ { value: "openai-completions", label: "OpenAI Completions (default)" },
+ { value: "anthropic", label: "Anthropic" },
+ { value: "ollama", label: "Ollama native" },
+];
+
+const CONTEXT_WINDOWS = [
+ { label: "4K", value: 4096 },
+ { label: "8K", value: 8192 },
+ { label: "16K", value: 16384 },
+ { label: "32K", value: 32768 },
+ { label: "64K", value: 65536 },
+ { label: "128K", value: 131072 },
+ { label: "200K", value: 200000 },
+];
+
+const MAX_TOKENS_OPTIONS = [
+ { label: "2K", value: 2048 },
+ { label: "4K", value: 4096 },
+ { label: "8K", value: 8192 },
+ { label: "16K", value: 16384 },
+ { label: "32K", value: 32768 },
+];
+
+const WORKSPACE_PRESETS = [
+ { label: "~/.openclaw (default)", value: "~/.openclaw" },
+ { label: "~/openclaw-workspace", value: "~/openclaw-workspace" },
+ { label: "/tmp/openclaw", value: "/tmp/openclaw" },
+ { label: "Custom…", value: "__custom__" },
+];
+
+const PROVIDER_PRESETS = [
+ { label: "Ollama (local)", name: "ollama", baseUrl: "http://127.0.0.1:11434/v1", apiKey: "ollama", api: "openai-completions" },
+ { label: "LM Studio", name: "lmstudio", baseUrl: "http://127.0.0.1:1234/v1", apiKey: "lmstudio", api: "openai-completions" },
+ { label: "OpenAI", name: "openai", baseUrl: "https://api.openai.com/v1", apiKey: "", api: "openai-completions" },
+ { label: "Anthropic", name: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "", api: "anthropic" },
+ { label: "Groq", name: "groq", baseUrl: "https://api.groq.com/openai/v1", apiKey: "", api: "openai-completions" },
+ { label: "OpenRouter", name: "openrouter", baseUrl: "https://openrouter.ai/api/v1", apiKey: "", api: "openai-completions" },
+ { label: "Custom…", name: "", baseUrl: "", apiKey: "", api: "openai-completions" },
+];
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function CardTitle({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+function Field({ label, sub, children }: { label: string; sub?: string; children: React.ReactNode }) {
+ return (
+
+
+
{label}
+ {sub &&
{sub}
}
+
+
{children}
+
+ );
+}
+
+// Standard dropdown
+function Select({
+ value, onChange, options, wide = false,
+}: {
+ value: string; onChange: (v: string) => void;
+ options: { value: string; label: string }[];
+ wide?: boolean;
+}) {
+ return (
+
+ );
+}
+
+// Combo: dropdown with a custom text fallback
+function ComboSelect({
+ value, onChange, options, placeholder = "Custom value…", wide = false, mono = false,
+}: {
+ value: string; onChange: (v: string) => void;
+ options: { value: string; label: string }[];
+ placeholder?: string; wide?: boolean; mono?: boolean;
+}) {
+ const isKnown = options.some((o) => o.value === value);
+ const [showCustom, setShowCustom] = useState(!isKnown && value !== "");
+
+ return (
+
+
+ {showCustom && (
+ onChange(e.target.value)}
+ autoFocus
+ className={`bg-white/[0.04] border border-indigo-500/30 rounded-lg px-3 py-1.5 text-[13px]
+ text-white/70 outline-none focus:ring-1 focus:ring-indigo-500/40 w-full
+ placeholder-white/15 transition-all ${mono ? "font-mono" : ""}`}
+ />
+ )}
+
+ );
+}
+
+// Number select from preset list
+function SelectNumber({
+ value, onChange, options,
+}: { value: number; onChange: (v: number) => void; options: { label: string; value: number }[] }) {
+ const isKnown = options.some((o) => o.value === value);
+ return (
+
+ );
+}
+
+function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
+ return (
+
+ );
+}
+
+// Secret/token field with show/hide toggle
+function TokenInput({ value, onChange, placeholder = "" }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
+ const [show, setShow] = useState(false);
+ return (
+
+ onChange(e.target.value)}
+ className="flex-1 bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-1.5
+ text-[13px] font-mono text-white/70 outline-none focus:ring-1 focus:ring-indigo-500/40
+ focus:border-indigo-500/25 placeholder-white/15 transition-all min-w-0"
+ />
+
+
+ );
+}
+
+function SaveBar({ status, onSave, onReload }: { status: string; onSave: () => void; onReload: () => void }) {
+ return (
+
+
+ {status === "saving" && (
+ {[0,1,2].map((i) => (
+
+ ))}
+ )}
+ {status === "saved" && ✓ Saved to disk}
+ {status === "error" && ✗ Save failed}
+ {status === "idle" && Unsaved changes}
+
+
+
+
+
+
+ );
+}
+
+// ─── Main ──────────────────────────────────────────────────────────────────────
+
+type Section = "gateway" | "models" | "agents" | "plugins" | "channels" | "raw";
+
+export default function OpenClawSettings() {
+ const { config, rawJson, setRawJson, configPath, loading, saveStatus, error, load, save, saveRaw, patch } =
+ useOpenClawConfig();
+
+ const [section, setSection] = useState("gateway");
+ const [dirty, setDirty] = useState(false);
+ const [expandedProvider, setExpandedProvider] = useState(null);
+ const [selectedPreset, setSelectedPreset] = useState("");
+
+ const update = (key: K, val: OpenClawConfig[K]) => {
+ patch(key, val); setDirty(true);
+ };
+
+ const handleSave = () => { if (config) { save(config); setDirty(false); } };
+ const handleSaveRaw = () => { saveRaw(rawJson); setDirty(false); };
+
+ const sections: { id: Section; icon: string; label: string }[] = [
+ { id: "gateway", icon: "🌐", label: "Gateway" },
+ { id: "models", icon: "🤖", label: "Models" },
+ { id: "agents", icon: "⚡", label: "Agents" },
+ { id: "plugins", icon: "🔌", label: "Plugins" },
+ { id: "channels", icon: "💬", label: "Channels" },
+ { id: "raw", icon: "{ }", label: "Raw JSON" },
+ ];
+
+ if (loading) {
+ return (
+
+
{[0,1,2].map((i) => (
+
+ ))}
+
Reading openclaw.json…
+
+ );
+ }
+
+ if (error && !config) {
+ return (
+
+
⚠️
+
+
Could not load openclaw.json
+
{configPath}
+
{error}
+
+
+
+ );
+ }
+
+ const cfg = config!;
+
+ return (
+
+ {/* Path banner */}
+
+ 📄
+ {configPath}
+ {dirty && ● Unsaved}
+
+
+
+ {/* Section nav */}
+
+ {sections.map(({ id, icon, label }) => (
+
+ ))}
+
+
+ {/* Content */}
+
+
+ {/* ── GATEWAY ── */}
+ {section === "gateway" && (
+ <>
+
+ Connection
+
+
+
+
+
+
+
+
+
+ Auth
+
+
+ {cfg.gateway?.auth?.mode !== "none" && (
+
+ update("gateway", {
+ ...cfg.gateway, auth: { ...cfg.gateway?.auth, token: v },
+ })} />
+
+ )}
+
+
+
+ Tailscale
+
+
+
+ update("gateway", {
+ ...cfg.gateway,
+ tailscale: { ...cfg.gateway?.tailscale, resetOnExit: !cfg.gateway?.tailscale?.resetOnExit },
+ })} />
+
+
+ >
+ )}
+
+ {/* ── MODELS ── */}
+ {section === "models" && (
+ <>
+
+ Primary Model
+
+ update("agents", {
+ ...cfg.agents,
+ defaults: { ...cfg.agents?.defaults, model: { primary: v } },
+ })}
+ options={OLLAMA_MODELS.map((m) => ({ value: m, label: m }))}
+ placeholder="provider/model:tag"
+ wide mono />
+
+
+
+ {/* Providers */}
+ {Object.entries(cfg.models?.providers ?? {}).map(([name, provider]) => (
+
+
+
{name}
+
+
+
+
+
+
+
+ update("models", {
+ ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, baseUrl: v } },
+ })}
+ options={BASE_URLS.map((u) => ({ value: u.value, label: u.label }))}
+ placeholder="https://…"
+ wide mono />
+
+
+
+ update("models", {
+ ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, apiKey: v } },
+ })} />
+
+
+
+
+
+ {/* Models */}
+ {expandedProvider === name && (
+
+
+ Models ({provider.models.length})
+
+ {provider.models.map((m, idx) => (
+
+
+
+
Model ID
+
{
+ const models = provider.models.map((mm, i) => i === idx ? { ...mm, id: v } : mm);
+ update("models", { ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
+ }}
+ options={OLLAMA_MODELS.map((mo) => ({ value: mo, label: mo }))}
+ placeholder="model-id"
+ wide mono />
+
+
+
Display Name
+
{
+ const models = provider.models.map((mm, i) => i === idx ? { ...mm, name: v } : mm);
+ update("models", { ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
+ }}
+ options={OLLAMA_MODELS.map((mo) => ({ value: mo.split("/").pop() ?? mo, label: mo.split("/").pop() ?? mo }))}
+ placeholder="Display name"
+ wide />
+
+
+
+
+
Context Window
+
{
+ const models = provider.models.map((mm, i) => i === idx ? { ...mm, contextWindow: v } : mm);
+ update("models", { ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
+ }} />
+
+
+
Max Output Tokens
+
{
+ const models = provider.models.map((mm, i) => i === idx ? { ...mm, maxTokens: v } : mm);
+ update("models", { ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
+ }} />
+
+
+
+
+ {
+ const models = provider.models.map((mm, i) => i === idx ? { ...mm, reasoning: !mm.reasoning } : mm);
+ update("models", { ...cfg.models,
+ providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
+ }} />
+ Reasoning model
+
+
+
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ {/* Add provider */}
+
+ Add Provider
+
+
+ {selectedPreset !== "" && (() => {
+ const preset = PROVIDER_PRESETS.find((p) => p.name === selectedPreset);
+ if (!preset) return null;
+ return (
+
+
+
+ Name
+ {preset.name || "custom"}
+
+
+ Base URL
+ {preset.baseUrl || "—"}
+
+
+ API
+ {preset.api}
+
+
+
+
+ );
+ })()}
+
+ >
+ )}
+
+ {/* ── AGENTS ── */}
+ {section === "agents" && (
+
+ Agent Defaults
+
+
+
+
+
+
+
+ update("agents", {
+ ...cfg.agents,
+ defaults: { ...cfg.agents?.defaults, workspace: v },
+ })}
+ options={WORKSPACE_PRESETS.filter((p) => p.value !== "__custom__")}
+ placeholder="/path/to/workspace"
+ wide mono />
+
+
+ )}
+
+ {/* ── PLUGINS ── */}
+ {section === "plugins" && (
+
+ Plugin Entries
+ {Object.entries(cfg.plugins?.entries ?? {}).length === 0 ? (
+ No plugins configured.
+ ) : (
+ Object.entries(cfg.plugins?.entries ?? {}).map(([name, entry]) => (
+
+ update("plugins", {
+ ...cfg.plugins,
+ entries: { ...cfg.plugins?.entries, [name]: { ...entry, enabled: !entry.enabled } },
+ })} />
+
+ ))
+ )}
+
+ )}
+
+ {/* ── CHANNELS ── */}
+ {section === "channels" && (
+
+ Discord
+
+ update("channels", {
+ ...cfg.channels,
+ discord: { ...cfg.channels?.discord, enabled: !cfg.channels?.discord?.enabled },
+ })} />
+
+ {cfg.channels?.discord?.enabled && (
+ <>
+
+ update("channels", {
+ ...cfg.channels,
+ discord: { ...cfg.channels?.discord, token: v },
+ })} />
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* ── RAW JSON ── */}
+ {section === "raw" && (
+
+
+
Direct JSON editor — overrides GUI changes above
+
+
+
+ )}
+
+
+
+
+ {section !== "raw" && dirty && (
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useOpenClawConfig.ts b/src/hooks/useOpenClawConfig.ts
new file mode 100644
index 0000000..d398070
--- /dev/null
+++ b/src/hooks/useOpenClawConfig.ts
@@ -0,0 +1,124 @@
+import { useState, useEffect, useCallback } from "react";
+
+export interface OllamaModel {
+ id: string;
+ name: string;
+ reasoning: boolean;
+ input: string[];
+ contextWindow: number;
+ maxTokens: number;
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
+}
+
+export interface ModelProvider {
+ baseUrl: string;
+ apiKey: string;
+ api: string;
+ models: OllamaModel[];
+}
+
+export interface OpenClawConfig {
+ messages?: { ackReactionScope?: string };
+ agents?: {
+ defaults?: {
+ maxConcurrent?: number;
+ subagents?: { maxConcurrent?: number };
+ compaction?: { mode?: string };
+ workspace?: string;
+ model?: { primary?: string };
+ };
+ };
+ gateway?: {
+ mode?: string;
+ port?: number;
+ bind?: string;
+ auth?: { mode?: string; token?: string };
+ tailscale?: { mode?: string; resetOnExit?: boolean };
+ };
+ models?: {
+ providers?: Record;
+ };
+ plugins?: {
+ entries?: Record;
+ };
+ channels?: {
+ discord?: { enabled?: boolean; token?: string; groupPolicy?: string };
+ };
+ skills?: { install?: { nodeManager?: string } };
+ meta?: { lastTouchedVersion?: string; lastTouchedAt?: string };
+}
+
+type SaveStatus = "idle" | "saving" | "saved" | "error";
+
+export function useOpenClawConfig() {
+ const [config, setConfig] = useState(null);
+ const [rawJson, setRawJson] = useState("");
+ const [configPath, setConfigPath] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [saveStatus, setSaveStatus] = useState("idle");
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/openclaw-config");
+ const data = await res.json();
+ if (data.ok) {
+ setConfig(data.config);
+ setRawJson(JSON.stringify(data.config, null, 2));
+ setConfigPath(data.path);
+ } else {
+ setError(data.error);
+ }
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const save = useCallback(async (cfg: OpenClawConfig) => {
+ setSaveStatus("saving");
+ try {
+ const res = await fetch("/api/openclaw-config", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ config: cfg }),
+ });
+ const data = await res.json();
+ if (data.ok) {
+ setSaveStatus("saved");
+ setConfig(cfg);
+ setRawJson(JSON.stringify(cfg, null, 2));
+ setTimeout(() => setSaveStatus("idle"), 2000);
+ } else {
+ setSaveStatus("error");
+ setError(data.error);
+ }
+ } catch (e) {
+ setSaveStatus("error");
+ setError(String(e));
+ }
+ }, []);
+
+ const saveRaw = useCallback(async (raw: string) => {
+ try {
+ const parsed = JSON.parse(raw);
+ await save(parsed);
+ } catch {
+ setSaveStatus("error");
+ setError("Invalid JSON — fix syntax errors before saving");
+ }
+ }, [save]);
+
+ const patch = useCallback((
+ key: K, value: OpenClawConfig[K]
+ ) => {
+ setConfig((prev) => prev ? { ...prev, [key]: value } : prev);
+ }, []);
+
+ return { config, rawJson, setRawJson, configPath, loading, saveStatus, error, load, save, saveRaw, patch };
+}