openclaw config
This commit is contained in:
47
src/app/api/openclaw-config/route.ts
Normal file
47
src/app/api/openclaw-config/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<button key={id} onClick={() => setSettingsSection(id)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-[13px] transition-all text-left w-full
|
||||
${settingsSection === id ? "font-medium" : "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}
|
||||
@@ -955,6 +960,7 @@ export default function Home() {
|
||||
{{
|
||||
voice: "Voice & TTS", model: "Model", ui: "Appearance",
|
||||
behaviour: "Behaviour", account: "Profile", storage: "Storage",
|
||||
openclaw: "OpenClaw",
|
||||
}[settingsSection]}
|
||||
</p>
|
||||
|
||||
@@ -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
|
||||
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* ─ OpenClaw ─ */}
|
||||
{settingsSection === "openclaw" && (
|
||||
<div className="-mx-8 -my-6 flex flex-col" style={{ minHeight: "100%" }}>
|
||||
<OpenClawSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
782
src/components/OpenClawSettings.tsx
Normal file
782
src/components/OpenClawSettings.tsx
Normal file
@@ -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 (
|
||||
<div className={`rounded-xl border border-white/[0.06] bg-white/[0.025] p-5 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[11px] font-bold text-white/25 uppercase tracking-widest mb-4">{children}</p>;
|
||||
}
|
||||
|
||||
function Field({ label, sub, children }: { label: string; sub?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-6 py-3 border-b border-white/[0.05] last:border-0">
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="text-[13px] font-medium text-white/70 leading-snug">{label}</p>
|
||||
{sub && <p className="text-[11px] text-white/25 mt-0.5 leading-relaxed">{sub}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 mt-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard dropdown
|
||||
function Select({
|
||||
value, onChange, options, wide = false,
|
||||
}: {
|
||||
value: string; onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className={`bg-white/[0.06] border border-white/[0.09] rounded-lg px-3 py-1.5 text-[13px]
|
||||
text-white/70 outline-none cursor-pointer transition-all hover:bg-white/[0.09]
|
||||
focus:ring-1 focus:ring-indigo-500/40 ${wide ? "w-64" : "w-48"}`}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-[#1a1a2e]">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={`flex flex-col gap-1.5 ${wide ? "w-64" : "w-48"}`}>
|
||||
<select
|
||||
value={showCustom ? "__custom__" : value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "__custom__") {
|
||||
setShowCustom(true);
|
||||
} else {
|
||||
setShowCustom(false);
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="bg-white/[0.06] border border-white/[0.09] rounded-lg px-3 py-1.5 text-[13px]
|
||||
text-white/70 outline-none cursor-pointer transition-all hover:bg-white/[0.09]
|
||||
focus:ring-1 focus:ring-indigo-500/40 w-full">
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-[#1a1a2e]">{o.label}</option>
|
||||
))}
|
||||
<option value="__custom__" className="bg-[#1a1a2e]">Custom…</option>
|
||||
</select>
|
||||
{showCustom && (
|
||||
<input
|
||||
type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => 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" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<select
|
||||
value={isKnown ? value : "__custom__"}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="bg-white/[0.06] border border-white/[0.09] rounded-lg px-3 py-1.5 text-[13px]
|
||||
text-white/70 outline-none cursor-pointer transition-all hover:bg-white/[0.09]
|
||||
focus:ring-1 focus:ring-indigo-500/40 w-32">
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-[#1a1a2e]">{o.label} ({o.value.toLocaleString()})</option>
|
||||
))}
|
||||
{!isKnown && <option value={value} className="bg-[#1a1a2e]">Custom ({value.toLocaleString()})</option>}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors
|
||||
${on ? "bg-indigo-500" : "bg-white/10"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform
|
||||
${on ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-1.5 w-64">
|
||||
<input
|
||||
type={show ? "text" : "password"} value={value} placeholder={placeholder}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button onClick={() => setShow((v) => !v)}
|
||||
className="text-[11px] text-white/20 hover:text-white/50 transition-colors px-1.5 py-1.5
|
||||
rounded-lg hover:bg-white/[0.06] shrink-0">
|
||||
{show ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({ status, onSave, onReload }: { status: string; onSave: () => void; onReload: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-white/[0.06]
|
||||
bg-[#0d0d18]/90 backdrop-blur-xl shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{status === "saving" && (
|
||||
<span className="flex gap-0.5">{[0,1,2].map((i) => (
|
||||
<span key={i} className="w-1 h-1 bg-indigo-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: `${i*0.12}s` }} />
|
||||
))}</span>
|
||||
)}
|
||||
{status === "saved" && <span className="text-[12px] text-emerald-400">✓ Saved to disk</span>}
|
||||
{status === "error" && <span className="text-[12px] text-red-400">✗ Save failed</span>}
|
||||
{status === "idle" && <span className="text-[12px] text-white/20">Unsaved changes</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onReload}
|
||||
className="text-[12px] text-white/25 hover:text-white/55 transition-colors px-3 py-1.5">
|
||||
↺ Reload
|
||||
</button>
|
||||
<button onClick={onSave}
|
||||
className="text-[12px] bg-indigo-600 hover:bg-indigo-500 px-4 py-1.5 rounded-lg
|
||||
font-medium transition-all active:scale-95">
|
||||
Save to openclaw.json
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<Section>("gateway");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [selectedPreset, setSelectedPreset] = useState("");
|
||||
|
||||
const update = <K extends keyof OpenClawConfig>(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 (
|
||||
<div className="flex-1 flex items-center justify-center gap-3">
|
||||
<div className="flex gap-1">{[0,1,2].map((i) => (
|
||||
<span key={i} className="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: `${i*0.15}s` }} />
|
||||
))}</div>
|
||||
<span className="text-sm text-white/25">Reading openclaw.json…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !config) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<span className="text-4xl opacity-30">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/50 mb-1">Could not load openclaw.json</p>
|
||||
<p className="text-xs text-white/25 font-mono mb-4">{configPath}</p>
|
||||
<p className="text-xs text-red-400/70 max-w-sm">{error}</p>
|
||||
</div>
|
||||
<button onClick={load}
|
||||
className="px-4 py-2 bg-white/[0.05] hover:bg-white/[0.09] rounded-xl text-sm
|
||||
text-white/50 transition-all border border-white/[0.06]">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cfg = config!;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Path banner */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 border-b border-white/[0.05] bg-white/[0.01] shrink-0">
|
||||
<span className="text-[10px] text-white/20">📄</span>
|
||||
<span className="text-[11px] text-white/25 font-mono truncate flex-1">{configPath}</span>
|
||||
{dirty && <span className="text-[10px] text-amber-400/70 shrink-0">● Unsaved</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Section nav */}
|
||||
<div className="w-36 border-r border-white/[0.05] py-3 px-2 flex flex-col gap-0.5 shrink-0">
|
||||
{sections.map(({ id, icon, label }) => (
|
||||
<button key={id} onClick={() => setSection(id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-[12px] w-full text-left
|
||||
transition-all ${section === id
|
||||
? "bg-indigo-500/15 text-indigo-300 font-medium"
|
||||
: "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}>
|
||||
<span className="text-sm">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
||||
|
||||
{/* ── GATEWAY ── */}
|
||||
{section === "gateway" && (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle>Connection</CardTitle>
|
||||
<Field label="Mode" sub="How this instance connects">
|
||||
<Select value={cfg.gateway?.mode ?? "local"}
|
||||
onChange={(v) => update("gateway", { ...cfg.gateway, mode: v })}
|
||||
options={[
|
||||
{ value: "local", label: "Local (this machine)" },
|
||||
{ value: "remote", label: "Remote (connect to host)" },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Port" sub="Gateway listening port">
|
||||
<Select
|
||||
value={String(cfg.gateway?.port ?? 18789)}
|
||||
onChange={(v) => 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" },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Bind" sub="Network interface to listen on">
|
||||
<Select value={cfg.gateway?.bind ?? "loopback"}
|
||||
onChange={(v) => update("gateway", { ...cfg.gateway, bind: v })}
|
||||
options={[
|
||||
{ value: "loopback", label: "Loopback — 127.0.0.1 (local only)" },
|
||||
{ value: "all", label: "All interfaces — 0.0.0.0 (network)" },
|
||||
]} />
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Auth</CardTitle>
|
||||
<Field label="Auth Mode">
|
||||
<Select value={cfg.gateway?.auth?.mode ?? "token"}
|
||||
onChange={(v) => update("gateway", { ...cfg.gateway, auth: { ...cfg.gateway?.auth, mode: v } })}
|
||||
options={[
|
||||
{ value: "token", label: "Bearer Token (recommended)" },
|
||||
{ value: "none", label: "None (no auth)" },
|
||||
]} />
|
||||
</Field>
|
||||
{cfg.gateway?.auth?.mode !== "none" && (
|
||||
<Field label="Auth Token" sub="Keep this secret — used to authenticate clients">
|
||||
<TokenInput
|
||||
value={cfg.gateway?.auth?.token ?? ""}
|
||||
placeholder="your-secret-token"
|
||||
onChange={(v) => update("gateway", {
|
||||
...cfg.gateway, auth: { ...cfg.gateway?.auth, token: v },
|
||||
})} />
|
||||
</Field>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Tailscale</CardTitle>
|
||||
<Field label="Mode" sub="Enable Tailscale overlay networking">
|
||||
<Select value={cfg.gateway?.tailscale?.mode ?? "off"}
|
||||
onChange={(v) => update("gateway", {
|
||||
...cfg.gateway, tailscale: { ...cfg.gateway?.tailscale, mode: v },
|
||||
})}
|
||||
options={[
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "on", label: "On (allow Tailscale peers)" },
|
||||
{ value: "strict", label: "Strict (Tailscale only)" },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Reset on Exit" sub="Disconnect Tailscale when OpenClaw stops">
|
||||
<Toggle on={cfg.gateway?.tailscale?.resetOnExit ?? false}
|
||||
onToggle={() => update("gateway", {
|
||||
...cfg.gateway,
|
||||
tailscale: { ...cfg.gateway?.tailscale, resetOnExit: !cfg.gateway?.tailscale?.resetOnExit },
|
||||
})} />
|
||||
</Field>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── MODELS ── */}
|
||||
{section === "models" && (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle>Primary Model</CardTitle>
|
||||
<Field label="Primary" sub="Default model used for all agent tasks">
|
||||
<ComboSelect
|
||||
value={cfg.agents?.defaults?.model?.primary ?? ""}
|
||||
onChange={(v) => 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 />
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
{/* Providers */}
|
||||
{Object.entries(cfg.models?.providers ?? {}).map(([name, provider]) => (
|
||||
<Card key={name}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<CardTitle>{name}</CardTitle>
|
||||
<div className="flex gap-2 -mt-3 mb-3">
|
||||
<button onClick={() => setExpandedProvider(expandedProvider === name ? null : name)}
|
||||
className="text-[11px] text-white/25 hover:text-white/55 transition-colors">
|
||||
{expandedProvider === name ? "Collapse models" : `Edit models (${provider.models.length})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const providers = { ...cfg.models?.providers };
|
||||
delete providers[name];
|
||||
update("models", { ...cfg.models, providers });
|
||||
}}
|
||||
className="text-[11px] text-red-400/30 hover:text-red-400 transition-colors">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Base URL" sub="API endpoint for this provider">
|
||||
<ComboSelect
|
||||
value={provider.baseUrl}
|
||||
onChange={(v) => 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 />
|
||||
</Field>
|
||||
|
||||
<Field label="API Key" sub="Authentication key for this provider">
|
||||
<TokenInput
|
||||
value={provider.apiKey}
|
||||
placeholder="sk-…"
|
||||
onChange={(v) => update("models", {
|
||||
...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, apiKey: v } },
|
||||
})} />
|
||||
</Field>
|
||||
|
||||
<Field label="API Type" sub="Protocol used to call this provider">
|
||||
<Select value={provider.api}
|
||||
onChange={(v) => update("models", {
|
||||
...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, api: v } },
|
||||
})}
|
||||
options={API_TYPES} />
|
||||
</Field>
|
||||
|
||||
{/* Models */}
|
||||
{expandedProvider === name && (
|
||||
<div className="mt-5 space-y-3">
|
||||
<p className="text-[11px] text-white/20 font-semibold uppercase tracking-widest">
|
||||
Models ({provider.models.length})
|
||||
</p>
|
||||
{provider.models.map((m, idx) => (
|
||||
<div key={idx} className="bg-white/[0.03] rounded-xl p-4 border border-white/[0.05] space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[10px] text-white/25 mb-1.5">Model ID</p>
|
||||
<ComboSelect
|
||||
value={m.id}
|
||||
onChange={(v) => {
|
||||
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 />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-white/25 mb-1.5">Display Name</p>
|
||||
<ComboSelect
|
||||
value={m.name}
|
||||
onChange={(v) => {
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[10px] text-white/25 mb-1.5">Context Window</p>
|
||||
<SelectNumber value={m.contextWindow}
|
||||
options={CONTEXT_WINDOWS}
|
||||
onChange={(v) => {
|
||||
const models = provider.models.map((mm, i) => i === idx ? { ...mm, contextWindow: v } : mm);
|
||||
update("models", { ...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-white/25 mb-1.5">Max Output Tokens</p>
|
||||
<SelectNumber value={m.maxTokens}
|
||||
options={MAX_TOKENS_OPTIONS}
|
||||
onChange={(v) => {
|
||||
const models = provider.models.map((mm, i) => i === idx ? { ...mm, maxTokens: v } : mm);
|
||||
update("models", { ...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle on={m.reasoning}
|
||||
onToggle={() => {
|
||||
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 } } });
|
||||
}} />
|
||||
<span className="text-[12px] text-white/40">Reasoning model</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const models = provider.models.filter((_, i) => i !== idx);
|
||||
update("models", { ...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
|
||||
}}
|
||||
className="text-[11px] text-red-400/30 hover:text-red-400 transition-colors">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const blank: OllamaModel = {
|
||||
id: "ollama/llama3.1:8b", name: "Llama 3.1 8B", reasoning: false,
|
||||
input: ["text"], contextWindow: 131072, maxTokens: 8192,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
const models = [...provider.models, blank];
|
||||
update("models", { ...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [name]: { ...provider, models } } });
|
||||
}}
|
||||
className="w-full py-2.5 rounded-xl border border-dashed border-white/[0.08]
|
||||
text-[12px] text-white/25 hover:text-white/50 hover:border-white/20 transition-all">
|
||||
+ Add model
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Add provider */}
|
||||
<Card>
|
||||
<CardTitle>Add Provider</CardTitle>
|
||||
<Field label="Provider Preset" sub="Pick a preset to auto-fill the fields below">
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
onChange={(v) => setSelectedPreset(v)}
|
||||
options={[
|
||||
{ value: "", label: "— Select a preset —" },
|
||||
...PROVIDER_PRESETS.map((p) => ({ value: p.name, label: p.label })),
|
||||
]} />
|
||||
</Field>
|
||||
{selectedPreset !== "" && (() => {
|
||||
const preset = PROVIDER_PRESETS.find((p) => p.name === selectedPreset);
|
||||
if (!preset) return null;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-white/[0.05] space-y-2">
|
||||
<div className="bg-white/[0.03] rounded-xl p-3 text-[12px] space-y-1 border border-white/[0.05]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/25">Name</span>
|
||||
<span className="text-white/60 font-mono">{preset.name || "custom"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/25">Base URL</span>
|
||||
<span className="text-white/60 font-mono text-[11px]">{preset.baseUrl || "—"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/25">API</span>
|
||||
<span className="text-white/60">{preset.api}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!preset.name) return;
|
||||
const newProv: ModelProvider = {
|
||||
baseUrl: preset.baseUrl, apiKey: preset.apiKey,
|
||||
api: preset.api, models: [],
|
||||
};
|
||||
update("models", {
|
||||
...cfg.models,
|
||||
providers: { ...cfg.models?.providers, [preset.name]: newProv },
|
||||
});
|
||||
setSelectedPreset("");
|
||||
}}
|
||||
disabled={!preset.name}
|
||||
className="w-full py-2 rounded-xl text-[12px] font-medium bg-indigo-600
|
||||
hover:bg-indigo-500 disabled:opacity-20 transition-all">
|
||||
+ Add {preset.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── AGENTS ── */}
|
||||
{section === "agents" && (
|
||||
<Card>
|
||||
<CardTitle>Agent Defaults</CardTitle>
|
||||
<Field label="Max Concurrent Agents" sub="Parallel top-level agents">
|
||||
<Select
|
||||
value={String(cfg.agents?.defaults?.maxConcurrent ?? 4)}
|
||||
onChange={(v) => 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) }))} />
|
||||
</Field>
|
||||
<Field label="Max Concurrent Subagents" sub="Parallel child agents per parent">
|
||||
<Select
|
||||
value={String(cfg.agents?.defaults?.subagents?.maxConcurrent ?? 8)}
|
||||
onChange={(v) => update("agents", {
|
||||
...cfg.agents,
|
||||
defaults: { ...cfg.agents?.defaults, subagents: { maxConcurrent: Number(v) } },
|
||||
})}
|
||||
options={[1,2,4,8,16,32,64].map((n) => ({ value: String(n), label: String(n) }))} />
|
||||
</Field>
|
||||
<Field label="Compaction Mode" sub="How context is managed when nearing window limit">
|
||||
<Select
|
||||
value={cfg.agents?.defaults?.compaction?.mode ?? "safeguard"}
|
||||
onChange={(v) => 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" },
|
||||
]} />
|
||||
</Field>
|
||||
<Field label="Workspace" sub="Default working directory for agents">
|
||||
<ComboSelect
|
||||
value={cfg.agents?.defaults?.workspace ?? "~/.openclaw"}
|
||||
onChange={(v) => update("agents", {
|
||||
...cfg.agents,
|
||||
defaults: { ...cfg.agents?.defaults, workspace: v },
|
||||
})}
|
||||
options={WORKSPACE_PRESETS.filter((p) => p.value !== "__custom__")}
|
||||
placeholder="/path/to/workspace"
|
||||
wide mono />
|
||||
</Field>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── PLUGINS ── */}
|
||||
{section === "plugins" && (
|
||||
<Card>
|
||||
<CardTitle>Plugin Entries</CardTitle>
|
||||
{Object.entries(cfg.plugins?.entries ?? {}).length === 0 ? (
|
||||
<p className="text-[13px] text-white/20 py-2">No plugins configured.</p>
|
||||
) : (
|
||||
Object.entries(cfg.plugins?.entries ?? {}).map(([name, entry]) => (
|
||||
<Field key={name} label={name}>
|
||||
<Toggle on={entry.enabled}
|
||||
onToggle={() => update("plugins", {
|
||||
...cfg.plugins,
|
||||
entries: { ...cfg.plugins?.entries, [name]: { ...entry, enabled: !entry.enabled } },
|
||||
})} />
|
||||
</Field>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── CHANNELS ── */}
|
||||
{section === "channels" && (
|
||||
<Card>
|
||||
<CardTitle>Discord</CardTitle>
|
||||
<Field label="Enable Discord Bot">
|
||||
<Toggle on={cfg.channels?.discord?.enabled ?? false}
|
||||
onToggle={() => update("channels", {
|
||||
...cfg.channels,
|
||||
discord: { ...cfg.channels?.discord, enabled: !cfg.channels?.discord?.enabled },
|
||||
})} />
|
||||
</Field>
|
||||
{cfg.channels?.discord?.enabled && (
|
||||
<>
|
||||
<Field label="Bot Token" sub="From Discord Developer Portal">
|
||||
<TokenInput
|
||||
value={cfg.channels?.discord?.token ?? ""}
|
||||
placeholder="Bot token…"
|
||||
onChange={(v) => update("channels", {
|
||||
...cfg.channels,
|
||||
discord: { ...cfg.channels?.discord, token: v },
|
||||
})} />
|
||||
</Field>
|
||||
<Field label="Group Policy" sub="Who can interact with the bot">
|
||||
<Select
|
||||
value={cfg.channels?.discord?.groupPolicy ?? "open"}
|
||||
onChange={(v) => update("channels", {
|
||||
...cfg.channels,
|
||||
discord: { ...cfg.channels?.discord, groupPolicy: v },
|
||||
})}
|
||||
options={[
|
||||
{ value: "open", label: "Open — anyone can use it" },
|
||||
{ value: "allowlist", label: "Allowlist — approved users" },
|
||||
{ value: "admin-only", label: "Admins only" },
|
||||
]} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── RAW JSON ── */}
|
||||
{section === "raw" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-white/25">Direct JSON editor — overrides GUI changes above</p>
|
||||
<button onClick={() => setRawJson(JSON.stringify(cfg, null, 2))}
|
||||
className="text-[11px] text-white/25 hover:text-white/55 transition-colors">
|
||||
↺ Sync from GUI
|
||||
</button>
|
||||
</div>
|
||||
<textarea value={rawJson} onChange={(e) => { setRawJson(e.target.value); setDirty(true); }}
|
||||
spellCheck={false}
|
||||
className="w-full h-[500px] bg-white/[0.03] border border-white/[0.06] rounded-xl
|
||||
p-4 text-[12px] font-mono text-white/60 outline-none focus:ring-1
|
||||
focus:ring-indigo-500/30 resize-none leading-relaxed" />
|
||||
<button onClick={handleSaveRaw}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-xl text-[13px]
|
||||
font-medium transition-all">
|
||||
Save Raw JSON
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{section !== "raw" && dirty && (
|
||||
<SaveBar status={saveStatus} onSave={handleSave} onReload={load} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/hooks/useOpenClawConfig.ts
Normal file
124
src/hooks/useOpenClawConfig.ts
Normal file
@@ -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<string, ModelProvider>;
|
||||
};
|
||||
plugins?: {
|
||||
entries?: Record<string, { enabled: boolean }>;
|
||||
};
|
||||
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<OpenClawConfig | null>(null);
|
||||
const [rawJson, setRawJson] = useState("");
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(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(<K extends keyof OpenClawConfig>(
|
||||
key: K, value: OpenClawConfig[K]
|
||||
) => {
|
||||
setConfig((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||
}, []);
|
||||
|
||||
return { config, rawJson, setRawJson, configPath, loading, saveStatus, error, load, save, saveRaw, patch };
|
||||
}
|
||||
Reference in New Issue
Block a user