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 }) => ( )} + + + {/* ─ 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 + + update("gateway", { ...cfg.gateway, port: Number(v) })} + options={[ + { value: "18789", label: "18789 (default)" }, + { value: "8080", label: "8080" }, + { value: "8443", label: "8443" }, + { value: "3000", label: "3000" }, + { value: "4000", label: "4000" }, + ]} /> + + + update("gateway", { ...cfg.gateway, auth: { ...cfg.gateway?.auth, mode: v } })} + options={[ + { value: "token", label: "Bearer Token (recommended)" }, + { value: "none", label: "None (no auth)" }, + ]} /> + + {cfg.gateway?.auth?.mode !== "none" && ( + + update("gateway", { + ...cfg.gateway, auth: { ...cfg.gateway?.auth, token: v }, + })} /> + + )} + + + + Tailscale + + update("models", { + ...cfg.models, + providers: { ...cfg.models?.providers, [name]: { ...provider, api: v } }, + })} + options={API_TYPES} /> + + + {/* 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 + + update("agents", { + ...cfg.agents, + defaults: { ...cfg.agents?.defaults, maxConcurrent: Number(v) }, + })} + options={[1,2,4,8,16,32].map((n) => ({ value: String(n), label: String(n) }))} /> + + + update("agents", { + ...cfg.agents, + defaults: { ...cfg.agents?.defaults, compaction: { mode: v } }, + })} + options={[ + { value: "safeguard", label: "Safeguard (recommended)" }, + { value: "aggressive", label: "Aggressive" }, + { value: "disabled", label: "Disabled" }, + ]} /> + + + 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 }, + })} /> + + +