feat: UI/UX overhaul — shared CreateAgentDialog, multi-platform Channels, skeleton loading, inline Chat panel
- Extract shared CreateAgentDialog with progressive disclosure (independent agent expands identity/persona fields)
- Redesign Channels page: self-contained, groups all channel types (Discord, Telegram, Feishu, QBot) with per-group refresh
- Add skeleton loading states for Home page agents and backups
- Replace Chat Sheet overlay with inline aside panel that pushes main content
- Add RecipeCard compact mode for Home page, with visible tag backgrounds
- Upgrade toast system to support multi-toast stacking with close buttons
- Unify SelectTrigger heights to size="sm" across all pages
- Add "Done" button to Cook completion card
- Replace ~25 silent .catch(() => {}) with console.error or user-visible feedback
- Fix create_agent to inherit default workspace for non-independent agents
- Register list_channels_minimal backend command for frontend access
- Remove unused globalLoading state and refreshDiscord from App.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -941,13 +941,19 @@ pub fn create_agent(
|
||||
} else { None };
|
||||
let model_display = model_value.flatten();
|
||||
|
||||
// If independent, create a workspace directory for this agent
|
||||
// If independent, create a dedicated workspace directory;
|
||||
// otherwise inherit the default workspace so the gateway doesn't auto-create one.
|
||||
let workspace = if independent.unwrap_or(false) {
|
||||
let ws_dir = paths.base_dir.join("workspaces").join(&agent_id);
|
||||
fs::create_dir_all(&ws_dir).map_err(|e| e.to_string())?;
|
||||
let ws_path = ws_dir.to_string_lossy().to_string();
|
||||
Some(ws_path)
|
||||
} else { None };
|
||||
} else {
|
||||
cfg.pointer("/agents/defaults/workspace")
|
||||
.or_else(|| cfg.pointer("/agents/default/workspace"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
// Build agent entry
|
||||
let mut agent_obj = serde_json::Map::new();
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::commands::{
|
||||
preview_rollback, rollback, run_doctor_command,
|
||||
resolve_api_keys, read_raw_config, resolve_full_api_key, open_url, chat_via_openclaw,
|
||||
backup_before_upgrade, list_backups, restore_from_backup, delete_backup,
|
||||
list_channels_minimal,
|
||||
list_discord_guild_channels,
|
||||
refresh_discord_guild_channels,
|
||||
restart_gateway,
|
||||
@@ -66,6 +67,7 @@ pub fn run() {
|
||||
list_backups,
|
||||
restore_from_backup,
|
||||
delete_backup,
|
||||
list_channels_minimal,
|
||||
list_discord_guild_channels,
|
||||
refresh_discord_guild_channels,
|
||||
restart_gateway,
|
||||
|
||||
141
src/App.tsx
141
src/App.tsx
@@ -6,17 +6,10 @@ import { History } from "./pages/History";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Channels } from "./pages/Channels";
|
||||
import { Chat } from "./components/Chat";
|
||||
import { GlobalLoading } from "./components/GlobalLoading";
|
||||
import { DiffViewer } from "./components/DiffViewer";
|
||||
import { api } from "./lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -39,12 +32,19 @@ import type { DiscordGuildChannel } from "./lib/types";
|
||||
|
||||
type Route = "home" | "recipes" | "cook" | "history" | "channels" | "settings";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
|
||||
let toastIdCounter = 0;
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState<Route>("home");
|
||||
const [recipeId, setRecipeId] = useState<string | null>(null);
|
||||
const [recipeSource, setRecipeSource] = useState<string | undefined>(undefined);
|
||||
const [discordGuildChannels, setDiscordGuildChannels] = useState<DiscordGuildChannel[]>([]);
|
||||
const [globalLoading, setGlobalLoading] = useState<string | null>(null);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
|
||||
// Config dirty state
|
||||
@@ -56,19 +56,19 @@ export function App() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState("");
|
||||
const [configVersion, setConfigVersion] = useState(0);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Establish baseline on startup
|
||||
useEffect(() => {
|
||||
api.saveConfigBaseline().catch(() => {});
|
||||
api.saveConfigBaseline().catch((e) => console.error("Failed to save config baseline:", e));
|
||||
}, []);
|
||||
|
||||
// Poll for dirty state
|
||||
const checkDirty = useCallback(() => {
|
||||
api.checkConfigDirty()
|
||||
.then((state) => setDirty(state.dirty))
|
||||
.catch(() => {});
|
||||
.catch((e) => console.error("Failed to check config dirty state:", e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,17 +84,9 @@ export function App() {
|
||||
if (!localStorage.getItem("clawpal_profiles_extracted")) {
|
||||
api.extractModelProfilesFromConfig()
|
||||
.then(() => localStorage.setItem("clawpal_profiles_extracted", "1"))
|
||||
.catch(() => {});
|
||||
.catch((e) => console.error("Failed to extract model profiles:", e));
|
||||
}
|
||||
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const refreshDiscord = useCallback(() => {
|
||||
setGlobalLoading("Resolving Discord channel names...");
|
||||
api.refreshDiscordGuildChannels()
|
||||
.then(setDiscordGuildChannels)
|
||||
.catch(() => {})
|
||||
.finally(() => setGlobalLoading(null));
|
||||
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch((e) => console.error("Failed to load Discord channels:", e));
|
||||
}, []);
|
||||
|
||||
const bumpConfigVersion = useCallback(() => {
|
||||
@@ -110,13 +102,20 @@ export function App() {
|
||||
setApplyError("");
|
||||
setShowApplyDialog(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((e) => console.error("Failed to load config diff:", e));
|
||||
};
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" = "success") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
|
||||
const id = ++toastIdCounter;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleApplyConfirm = () => {
|
||||
setApplying(true);
|
||||
@@ -145,7 +144,6 @@ export function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{globalLoading && <GlobalLoading message={globalLoading} />}
|
||||
<div className="flex h-screen">
|
||||
<aside className="w-[200px] min-w-[200px] bg-muted border-r border-border flex flex-col py-4">
|
||||
<h1 className="px-4 text-lg font-bold mb-4">ClawPal</h1>
|
||||
@@ -203,18 +201,6 @@ export function App() {
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
{/* Chat toggle */}
|
||||
<div className="px-2 pb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={() => setChatOpen(true)}
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Dirty config action bar */}
|
||||
{dirty && (
|
||||
<div className="px-2 pb-2 space-y-1.5">
|
||||
@@ -238,7 +224,19 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto p-4">
|
||||
<main className="flex-1 overflow-y-auto p-4 relative">
|
||||
{/* Chat toggle -- top-right corner */}
|
||||
{!chatOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 z-10"
|
||||
onClick={() => setChatOpen(true)}
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{route === "home" && (
|
||||
<Home
|
||||
key={configVersion}
|
||||
@@ -247,6 +245,7 @@ export function App() {
|
||||
setRecipeSource(source);
|
||||
setRoute("cook");
|
||||
}}
|
||||
showToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{route === "recipes" && (
|
||||
@@ -272,8 +271,7 @@ export function App() {
|
||||
{route === "channels" && (
|
||||
<Channels
|
||||
key={configVersion}
|
||||
discordGuildChannels={discordGuildChannels}
|
||||
onRefresh={refreshDiscord}
|
||||
showToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{route === "history" && <History key={configVersion} />}
|
||||
@@ -281,19 +279,27 @@ export function App() {
|
||||
<Settings key={configVersion} onDataChange={bumpConfigVersion} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Chat Drawer */}
|
||||
<Sheet open={chatOpen} onOpenChange={setChatOpen}>
|
||||
<SheetContent side="right" className="w-[380px] sm:w-[420px] p-0 flex flex-col">
|
||||
<SheetHeader className="px-4 pt-4 pb-2">
|
||||
<SheetTitle>Chat</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden px-4 pb-4">
|
||||
<Chat />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Chat Panel -- inline, pushes main content */}
|
||||
{chatOpen && (
|
||||
<aside className="w-[360px] min-w-[360px] border-l border-border flex flex-col bg-background">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<h2 className="text-lg font-semibold">Chat</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setChatOpen(false)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden px-4 pb-4">
|
||||
<Chat />
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Apply Changes Dialog */}
|
||||
<Dialog open={showApplyDialog} onOpenChange={setShowApplyDialog}>
|
||||
@@ -340,13 +346,26 @@ export function App() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={cn(
|
||||
"fixed bottom-4 right-4 px-4 py-2.5 rounded-md shadow-lg text-sm font-medium z-50 animate-in fade-in slide-in-from-bottom-2",
|
||||
toast.type === "success" ? "bg-green-600 text-white" : "bg-destructive text-destructive-foreground"
|
||||
)}>
|
||||
{toast.message}
|
||||
{/* Toast Stack */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2.5 rounded-md shadow-lg text-sm font-medium animate-in fade-in slide-in-from-bottom-2",
|
||||
toast.type === "success" ? "bg-green-600 text-white" : "bg-destructive text-destructive-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
className="opacity-70 hover:opacity-100 text-current ml-2"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Chat() {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listAgentIds().then(setAgents).catch(() => {});
|
||||
api.listAgentIds().then(setAgents).catch((e) => console.error("Failed to load agent IDs:", e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,7 +90,7 @@ export function Chat() {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Select value={agentId} onValueChange={(a) => { setAgentId(a); setSessionId(loadSessionId(a)); setMessages([]); }}>
|
||||
<SelectTrigger className="w-auto h-7 text-xs">
|
||||
<SelectTrigger size="sm" className="w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
187
src/components/CreateAgentDialog.tsx
Normal file
187
src/components/CreateAgentDialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ModelProfile } from "../lib/types";
|
||||
|
||||
export interface CreateAgentResult {
|
||||
agentId: string;
|
||||
persona?: string;
|
||||
}
|
||||
|
||||
export function CreateAgentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
modelProfiles,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modelProfiles: ModelProfile[];
|
||||
onCreated: (result: CreateAgentResult) => void;
|
||||
}) {
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [independent, setIndependent] = useState(false);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [emoji, setEmoji] = useState("");
|
||||
const [persona, setPersona] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const reset = () => {
|
||||
setAgentId("");
|
||||
setModel("");
|
||||
setIndependent(false);
|
||||
setDisplayName("");
|
||||
setEmoji("");
|
||||
setPersona("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const id = agentId.trim();
|
||||
if (!id) {
|
||||
setError("Agent ID is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await api.createAgent(id, model || undefined, independent || undefined);
|
||||
// Set identity if name or emoji provided
|
||||
const name = displayName.trim();
|
||||
const emojiVal = emoji.trim();
|
||||
if (independent && (name || emojiVal)) {
|
||||
await api.setupAgentIdentity(id, name || id, emojiVal || undefined).catch(() => {});
|
||||
}
|
||||
onOpenChange(false);
|
||||
const result: CreateAgentResult = { agentId: created.id };
|
||||
if (persona.trim()) result.persona = persona.trim();
|
||||
reset();
|
||||
onCreated(result);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. my-agent"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={model || "__default__"}
|
||||
onValueChange={(val) => setModel(val === "__default__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<span className="text-muted-foreground">use global default</span>
|
||||
</SelectItem>
|
||||
{modelProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.provider}/{p.model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="create-agent-independent"
|
||||
checked={independent}
|
||||
onCheckedChange={(checked) => {
|
||||
const val = checked === true;
|
||||
setIndependent(val);
|
||||
if (!val) {
|
||||
setDisplayName("");
|
||||
setEmoji("");
|
||||
setPersona("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="create-agent-independent">Independent agent (separate workspace)</Label>
|
||||
</div>
|
||||
{independent && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Display Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. MyBot"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Emoji</Label>
|
||||
<Input
|
||||
placeholder="e.g. \uD83E\uDD16"
|
||||
value={emoji}
|
||||
onChange={(e) => setEmoji(e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Persona</Label>
|
||||
<Textarea
|
||||
placeholder="You are..."
|
||||
value={persona}
|
||||
onChange={(e) => setPersona(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function ParamForm({
|
||||
const needsProfiles = recipe.params.some((p) => p.type === "model_profile");
|
||||
useEffect(() => {
|
||||
if (needsProfiles) {
|
||||
api.listModelProfiles().then(setModelProfiles).catch(() => {});
|
||||
api.listModelProfiles().then(setModelProfiles).catch((e) => console.error("Failed to load model profiles:", e));
|
||||
}
|
||||
}, [needsProfiles]);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ParamForm({
|
||||
const needsAgents = recipe.params.some((p) => p.type === "agent");
|
||||
useEffect(() => {
|
||||
if (needsAgents) {
|
||||
api.listAgentsOverview().then(setAgents).catch(() => {});
|
||||
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
|
||||
}
|
||||
}, [needsAgents]);
|
||||
|
||||
@@ -140,7 +140,7 @@ export function ParamForm({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={param.id} className="w-full">
|
||||
<SelectTrigger id={param.id} size="sm" className="w-full">
|
||||
<SelectValue placeholder="Select a guild" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -165,7 +165,7 @@ export function ParamForm({
|
||||
}}
|
||||
disabled={!guildSelected}
|
||||
>
|
||||
<SelectTrigger id={param.id} className="w-full">
|
||||
<SelectTrigger id={param.id} size="sm" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={guildSelected ? "Select a channel" : "Select a guild first"}
|
||||
/>
|
||||
@@ -190,7 +190,7 @@ export function ParamForm({
|
||||
setTouched((prev) => ({ ...prev, [param.id]: true }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={param.id} className="w-full">
|
||||
<SelectTrigger id={param.id} size="sm" className="w-full">
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -215,7 +215,7 @@ export function ParamForm({
|
||||
setTouched((prev) => ({ ...prev, [param.id]: true }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={param.id} className="w-full">
|
||||
<SelectTrigger id={param.id} size="sm" className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -3,7 +3,34 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function RecipeCard({ recipe, onCook }: { recipe: Recipe; onCook: (id: string) => void }) {
|
||||
export function RecipeCard({
|
||||
recipe,
|
||||
onCook,
|
||||
compact,
|
||||
}: {
|
||||
recipe: Recipe;
|
||||
onCook: (id: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => onCook(recipe.id)}
|
||||
>
|
||||
<CardContent>
|
||||
<strong>{recipe.name}</strong>
|
||||
<div className="text-sm text-muted-foreground mt-1.5">
|
||||
{recipe.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{recipe.steps.length} step{recipe.steps.length !== 1 ? "s" : ""} · {recipe.difficulty}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -13,7 +40,7 @@ export function RecipeCard({ recipe, onCook }: { recipe: Recipe; onCook: (id: st
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{recipe.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary">
|
||||
<Badge key={t} variant="secondary" className="bg-muted-foreground/15">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-muted animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AgentOverview, ApplyResult, BackupInfo, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
|
||||
import type { AgentOverview, ApplyResult, BackupInfo, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
|
||||
|
||||
export const api = {
|
||||
getSystemStatus: (): Promise<SystemStatus> =>
|
||||
@@ -78,6 +78,8 @@ export const api = {
|
||||
invoke("restore_from_backup", { backupName }),
|
||||
deleteBackup: (backupName: string): Promise<boolean> =>
|
||||
invoke("delete_backup", { backupName }),
|
||||
listChannelsMinimal: (): Promise<ChannelNode[]> =>
|
||||
invoke("list_channels_minimal", {}),
|
||||
listDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>
|
||||
invoke("list_discord_guild_channels", {}),
|
||||
refreshDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export type Severity = "low" | "medium" | "high";
|
||||
|
||||
export interface ChannelNode {
|
||||
path: string;
|
||||
channelType: string | null;
|
||||
mode: string | null;
|
||||
allowlist: string[];
|
||||
model: string | null;
|
||||
hasModelField: boolean;
|
||||
displayName: string | null;
|
||||
nameStatus: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordGuildChannel {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
|
||||
@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Parse various timestamp formats to local "YYYY-MM-DD HH:MM:SS".
|
||||
* Handles History dash-separated format ("2026-02-17T14-30-00")
|
||||
* and standard RFC3339 ("2026-02-17T14:30:00+00:00"). */
|
||||
export function formatTime(raw: string): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// Try History dash format: T followed by HH-MM-SS
|
||||
const dashMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
||||
if (dashMatch) {
|
||||
const [, y, mo, d, h, mi, s] = dashMatch;
|
||||
const utc = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
|
||||
if (!isNaN(utc.getTime())) {
|
||||
return `${utc.getFullYear()}-${pad(utc.getMonth() + 1)}-${pad(utc.getDate())} ${pad(utc.getHours())}:${pad(utc.getMinutes())}:${pad(utc.getSeconds())}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: RFC3339 / ISO 8601
|
||||
const date = new Date(raw);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AgentOverview, DiscordGuildChannel, ModelProfile } from "../lib/types";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { AgentOverview, ChannelNode, DiscordGuildChannel, ModelProfile } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -17,13 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CreateAgentDialog, type CreateAgentResult } from "@/components/CreateAgentDialog";
|
||||
|
||||
interface Binding {
|
||||
agentId: string;
|
||||
@@ -36,124 +27,158 @@ interface AgentGroup {
|
||||
agents: AgentOverview[];
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
discord: "Discord",
|
||||
telegram: "Telegram",
|
||||
feishu: "Feishu",
|
||||
qbot: "QBot",
|
||||
};
|
||||
|
||||
function groupAgents(agents: AgentOverview[]): AgentGroup[] {
|
||||
const map = new Map<string, AgentGroup>();
|
||||
for (const a of agents) {
|
||||
const key = a.workspace || a.id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
identity: a.name || a.id,
|
||||
emoji: a.emoji,
|
||||
agents: [],
|
||||
});
|
||||
map.set(key, { identity: a.name || a.id, emoji: a.emoji, agents: [] });
|
||||
}
|
||||
map.get(key)!.agents.push(a);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function extractPlatform(path: string): string | null {
|
||||
const parts = path.split(".");
|
||||
if (parts.length >= 2 && parts[0] === "channels") return parts[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPeerId(path: string): string {
|
||||
return path.split(".").pop() || path;
|
||||
}
|
||||
|
||||
export function Channels({
|
||||
discordGuildChannels,
|
||||
onRefresh,
|
||||
showToast,
|
||||
}: {
|
||||
discordGuildChannels: DiscordGuildChannel[];
|
||||
onRefresh: () => void;
|
||||
showToast?: (message: string, type?: "success" | "error") => void;
|
||||
}) {
|
||||
const [agents, setAgents] = useState<AgentOverview[]>([]);
|
||||
const [bindings, setBindings] = useState<Binding[]>([]);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [modelProfiles, setModelProfiles] = useState<ModelProfile[]>([]);
|
||||
const [channelNodes, setChannelNodes] = useState<ChannelNode[]>([]);
|
||||
const [discordChannels, setDiscordChannels] = useState<DiscordGuildChannel[]>([]);
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
// Create agent dialog
|
||||
const [showCreateAgent, setShowCreateAgent] = useState(false);
|
||||
const [pendingChannelId, setPendingChannelId] = useState<string | null>(null);
|
||||
const [newAgentId, setNewAgentId] = useState("");
|
||||
const [newAgentModel, setNewAgentModel] = useState("");
|
||||
const [newAgentIndependent, setNewAgentIndependent] = useState(false);
|
||||
const [creatingAgent, setCreatingAgent] = useState(false);
|
||||
const [createAgentError, setCreateAgentError] = useState("");
|
||||
const [pendingChannel, setPendingChannel] = useState<{
|
||||
platform: string;
|
||||
peerId: string;
|
||||
guildId?: string;
|
||||
} | null>(null);
|
||||
|
||||
const refreshAgents = () => {
|
||||
api.listAgentsOverview().then(setAgents).catch(() => {});
|
||||
};
|
||||
const refreshAgents = useCallback(() => {
|
||||
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
|
||||
}, []);
|
||||
|
||||
const refreshBindings = useCallback(() => {
|
||||
api.listBindings().then((b) => setBindings(b as unknown as Binding[])).catch((e) => console.error("Failed to load bindings:", e));
|
||||
}, []);
|
||||
|
||||
const refreshChannelNodes = useCallback(() => {
|
||||
api.listChannelsMinimal().then(setChannelNodes).catch((e) => console.error("Failed to load channel nodes:", e));
|
||||
}, []);
|
||||
|
||||
const refreshDiscordCache = useCallback(() => {
|
||||
api.listDiscordGuildChannels().then(setDiscordChannels).catch((e) => console.error("Failed to load Discord channels:", e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAgents();
|
||||
api.listBindings().then((b) => setBindings(b as unknown as Binding[])).catch(() => {});
|
||||
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch(() => {});
|
||||
}, []);
|
||||
refreshBindings();
|
||||
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
|
||||
refreshChannelNodes();
|
||||
refreshDiscordCache();
|
||||
}, [refreshAgents, refreshBindings, refreshChannelNodes, refreshDiscordCache]);
|
||||
|
||||
// Map channelId → agentId from bindings
|
||||
const handleRefreshDiscord = () => {
|
||||
setRefreshing("discord");
|
||||
api.refreshDiscordGuildChannels()
|
||||
.then((channels) => {
|
||||
setDiscordChannels(channels);
|
||||
showToast?.("Discord channels refreshed", "success");
|
||||
})
|
||||
.catch((e) => showToast?.(String(e), "error"))
|
||||
.finally(() => setRefreshing(null));
|
||||
};
|
||||
|
||||
const handleRefreshPlatform = (platform: string) => {
|
||||
setRefreshing(platform);
|
||||
api.listChannelsMinimal()
|
||||
.then((nodes) => {
|
||||
setChannelNodes(nodes);
|
||||
showToast?.(`${PLATFORM_LABELS[platform] || platform} channels refreshed`, "success");
|
||||
})
|
||||
.catch((e) => showToast?.(String(e), "error"))
|
||||
.finally(() => setRefreshing(null));
|
||||
};
|
||||
|
||||
// Binding lookup: "platform:peerId" -> agentId
|
||||
const channelAgentMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const b of bindings) {
|
||||
if (b.match?.channel === "discord" && b.match?.peer?.id) {
|
||||
map.set(b.match.peer.id, b.agentId);
|
||||
if (b.match?.channel && b.match?.peer?.id) {
|
||||
map.set(`${b.match.channel}:${b.match.peer.id}`, b.agentId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [bindings]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
// Discord channels grouped by guild
|
||||
const discordGuilds = useMemo(() => {
|
||||
const map = new Map<string, { guildName: string; channels: DiscordGuildChannel[] }>();
|
||||
for (const gc of discordGuildChannels) {
|
||||
if (!map.has(gc.guildId)) {
|
||||
map.set(gc.guildId, { guildName: gc.guildName, channels: [] });
|
||||
for (const ch of discordChannels) {
|
||||
if (!map.has(ch.guildId)) {
|
||||
map.set(ch.guildId, { guildName: ch.guildName, channels: [] });
|
||||
}
|
||||
map.get(gc.guildId)!.channels.push(gc);
|
||||
map.get(ch.guildId)!.channels.push(ch);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [discordGuildChannels]);
|
||||
}, [discordChannels]);
|
||||
|
||||
// Non-Discord channel nodes grouped by platform, filtered to leaf-level
|
||||
const otherPlatforms = useMemo(() => {
|
||||
const map = new Map<string, ChannelNode[]>();
|
||||
for (const node of channelNodes) {
|
||||
const platform = extractPlatform(node.path);
|
||||
if (!platform || platform === "discord") continue;
|
||||
if (node.channelType === "platform") continue;
|
||||
if (!map.has(platform)) map.set(platform, []);
|
||||
map.get(platform)!.push(node);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [channelNodes]);
|
||||
|
||||
const agentGroups = useMemo(() => groupAgents(agents), [agents]);
|
||||
|
||||
const handleAssign = async (channelId: string, agentId: string) => {
|
||||
const handleAssign = async (platform: string, peerId: string, agentId: string) => {
|
||||
if (agentId === "__new__") {
|
||||
setPendingChannelId(channelId);
|
||||
setPendingChannel({ platform, peerId });
|
||||
setShowCreateAgent(true);
|
||||
return;
|
||||
}
|
||||
setSaving(channelId);
|
||||
const key = `${platform}:${peerId}`;
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.assignChannelAgent(
|
||||
"discord",
|
||||
channelId,
|
||||
agentId === "__default__" ? null : agentId,
|
||||
);
|
||||
const updated = await api.listBindings();
|
||||
setBindings(updated as unknown as Binding[]);
|
||||
} catch {
|
||||
// silently fail
|
||||
await api.assignChannelAgent(platform, peerId, agentId === "__default__" ? null : agentId);
|
||||
refreshBindings();
|
||||
} catch (e) {
|
||||
showToast?.(String(e), "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAgent = () => {
|
||||
const id = newAgentId.trim();
|
||||
if (!id) {
|
||||
setCreateAgentError("Agent ID is required");
|
||||
return;
|
||||
}
|
||||
setCreatingAgent(true);
|
||||
setCreateAgentError("");
|
||||
api.createAgent(id, newAgentModel || undefined, newAgentIndependent || undefined)
|
||||
.then((created) => {
|
||||
setShowCreateAgent(false);
|
||||
setNewAgentId("");
|
||||
setNewAgentModel("");
|
||||
setNewAgentIndependent(false);
|
||||
refreshAgents();
|
||||
if (pendingChannelId) {
|
||||
handleAssign(pendingChannelId, created.id);
|
||||
setPendingChannelId(null);
|
||||
}
|
||||
})
|
||||
.catch((e) => setCreateAgentError(String(e)))
|
||||
.finally(() => setCreatingAgent(false));
|
||||
};
|
||||
|
||||
// Build the agent label shown in the trigger when selected
|
||||
function agentDisplayLabel(agentId: string): string {
|
||||
const a = agents.find((ag) => ag.id === agentId);
|
||||
if (!a) return agentId;
|
||||
@@ -163,149 +188,173 @@ export function Channels({
|
||||
return `${emoji}${name}: ${a.id}${model}`;
|
||||
}
|
||||
|
||||
const renderAgentSelect = (platform: string, peerId: string) => {
|
||||
const key = `${platform}:${peerId}`;
|
||||
const currentAgent = channelAgentMap.get(key);
|
||||
return (
|
||||
<Select
|
||||
value={currentAgent || "__default__"}
|
||||
onValueChange={(val) => handleAssign(platform, peerId, val)}
|
||||
disabled={saving === key}
|
||||
>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<SelectValue>
|
||||
{currentAgent ? agentDisplayLabel(currentAgent) : "main (default)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<span className="text-muted-foreground">main (default)</span>
|
||||
</SelectItem>
|
||||
{agentGroups.map((group, gi) => (
|
||||
<SelectGroup key={group.agents[0].workspace || group.agents[0].id}>
|
||||
{gi > 0 && <SelectSeparator />}
|
||||
<SelectLabel>
|
||||
{group.emoji ? `${group.emoji} ` : ""}{group.identity}
|
||||
</SelectLabel>
|
||||
{group.agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
<code className="text-xs">{a.id}</code>
|
||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||
{a.model || "default model"}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value="__new__">
|
||||
<span className="text-primary">+ New agent...</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
const hasDiscord = discordChannels.length > 0;
|
||||
const hasOther = otherPlatforms.length > 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold">Channels</h2>
|
||||
<Button onClick={onRefresh}>
|
||||
Refresh Discord channels
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4">Channels</h2>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
{!hasDiscord && !hasOther && (
|
||||
<p className="text-muted-foreground">
|
||||
No Discord channels cached. Click "Refresh Discord channels" to load.
|
||||
No channels configured. Add channel plugins in your OpenClaw config, then refresh to see them here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map(([guildId, { guildName, channels }]) => (
|
||||
<Card key={guildId}>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<strong className="text-lg">{guildName}</strong>
|
||||
<Badge variant="secondary">{guildId}</Badge>
|
||||
<Badge variant="outline" className="ml-auto">Discord</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
|
||||
{channels.map((ch) => {
|
||||
const currentAgent = channelAgentMap.get(ch.channelId);
|
||||
return (
|
||||
<div
|
||||
key={ch.channelId}
|
||||
className="rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="text-sm font-medium">{ch.channelName}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 mb-1.5">{ch.channelId}</div>
|
||||
<Select
|
||||
value={currentAgent || "__default__"}
|
||||
onValueChange={(val) => handleAssign(ch.channelId, val)}
|
||||
disabled={saving === ch.channelId}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue>
|
||||
{currentAgent ? agentDisplayLabel(currentAgent) : "main (default)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<span className="text-muted-foreground">main (default)</span>
|
||||
</SelectItem>
|
||||
{agentGroups.map((group, gi) => (
|
||||
<SelectGroup key={group.agents[0].workspace || group.agents[0].id}>
|
||||
{gi > 0 && <SelectSeparator />}
|
||||
<SelectLabel>
|
||||
{group.emoji ? `${group.emoji} ` : ""}{group.identity}
|
||||
</SelectLabel>
|
||||
{group.agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
<code className="text-xs">{a.id}</code>
|
||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||
{a.model || "default model"}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value="__new__">
|
||||
<span className="text-primary">+ New agent...</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Agent Dialog */}
|
||||
<Dialog open={showCreateAgent} onOpenChange={(open) => {
|
||||
setShowCreateAgent(open);
|
||||
if (!open) setPendingChannelId(null);
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. my-agent"
|
||||
value={newAgentId}
|
||||
onChange={(e) => setNewAgentId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={newAgentModel || "__default__"}
|
||||
onValueChange={(val) => setNewAgentModel(val === "__default__" ? "" : val)}
|
||||
<div className="space-y-6">
|
||||
{/* Discord section */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<strong className="text-lg">Discord</strong>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={handleRefreshDiscord}
|
||||
disabled={refreshing === "discord"}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<span className="text-muted-foreground">use global default</span>
|
||||
</SelectItem>
|
||||
{modelProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.provider}/{p.model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{refreshing === "discord" ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="ch-independent-agent"
|
||||
checked={newAgentIndependent}
|
||||
onCheckedChange={(checked) => setNewAgentIndependent(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="ch-independent-agent">Independent agent (separate workspace)</Label>
|
||||
</div>
|
||||
{createAgentError && (
|
||||
<p className="text-sm text-destructive">{createAgentError}</p>
|
||||
|
||||
{discordGuilds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No Discord channels cached. Click "Refresh" to discover channels from Discord.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{discordGuilds.map(([guildId, { guildName, channels }]) => (
|
||||
<div key={guildId}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-sm font-medium">{guildName}</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{guildId}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch.channelId} className="rounded-md border px-3 py-2">
|
||||
<div className="text-sm font-medium">{ch.channelName}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 mb-1.5">{ch.channelId}</div>
|
||||
{renderAgentSelect("discord", ch.channelId)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setShowCreateAgent(false); setPendingChannelId(null); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateAgent} disabled={creatingAgent}>
|
||||
{creatingAgent ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Other platform sections */}
|
||||
{otherPlatforms.map(([platform, nodes]) => (
|
||||
<Card key={platform}>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<strong className="text-lg">{PLATFORM_LABELS[platform] || platform}</strong>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => handleRefreshPlatform(platform)}
|
||||
disabled={refreshing === platform}
|
||||
>
|
||||
{refreshing === platform ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
|
||||
{nodes.map((node) => {
|
||||
const peerId = extractPeerId(node.path);
|
||||
return (
|
||||
<div key={node.path} className="rounded-md border px-3 py-2">
|
||||
<div className="text-sm font-medium">
|
||||
{node.displayName || peerId}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground mt-0.5 mb-1.5 truncate"
|
||||
title={node.path}
|
||||
>
|
||||
{node.path.length > 40 ? `...${node.path.slice(-37)}` : node.path}
|
||||
</div>
|
||||
{renderAgentSelect(platform, peerId)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Agent Dialog */}
|
||||
<CreateAgentDialog
|
||||
open={showCreateAgent}
|
||||
onOpenChange={(open) => {
|
||||
setShowCreateAgent(open);
|
||||
if (!open) setPendingChannel(null);
|
||||
}}
|
||||
modelProfiles={modelProfiles}
|
||||
onCreated={(result: CreateAgentResult) => {
|
||||
refreshAgents();
|
||||
if (pendingChannel) {
|
||||
handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId);
|
||||
// Apply persona as systemPrompt on the channel if provided
|
||||
if (result.persona && pendingChannel.platform === "discord") {
|
||||
const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId);
|
||||
if (ch) {
|
||||
const patch = JSON.stringify({
|
||||
channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } },
|
||||
});
|
||||
api.applyConfigPatch(patch, {}).catch((e) => showToast?.(String(e), "error"));
|
||||
}
|
||||
}
|
||||
setPendingChannel(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,9 @@ export function Cook({
|
||||
Use "Apply Changes" in the sidebar to restart the gateway and activate config changes.
|
||||
</p>
|
||||
)}
|
||||
<Button className="mt-4" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function Doctor() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listBackups().then(setBackups).catch(() => {});
|
||||
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,18 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { HistoryItem, PreviewResult } from "../lib/types";
|
||||
|
||||
/** Parse "2026-02-17T14-30-00-recipe" style timestamps to local YYYY-MM-DD HH:MM:SS */
|
||||
function formatHistoryTime(raw: string): string {
|
||||
// createdAt is like "2026-02-17T14-30-00" (UTC)
|
||||
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
||||
if (!match) return raw;
|
||||
const [, y, mo, d, h, mi, s] = match;
|
||||
const utc = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
|
||||
if (isNaN(utc.getTime())) return raw;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${utc.getFullYear()}-${pad(utc.getMonth() + 1)}-${pad(utc.getDate())} ${pad(utc.getHours())}:${pad(utc.getMinutes())}:${pad(utc.getSeconds())}`;
|
||||
}
|
||||
import { formatTime } from "@/lib/utils";
|
||||
|
||||
export function History() {
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
@@ -54,13 +43,13 @@ export function History() {
|
||||
<Card key={item.id} className={isRollback ? "border-dashed opacity-75" : ""}>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span className="text-muted-foreground">{formatHistoryTime(item.createdAt)}</span>
|
||||
<span className="text-muted-foreground">{formatTime(item.createdAt)}</span>
|
||||
{isRollback ? (
|
||||
<>
|
||||
<Badge variant="outline">rollback</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
Reverted {rollbackTarget
|
||||
? `"${rollbackTarget.recipeId || "manual"}" from ${formatHistoryTime(rollbackTarget.createdAt)}`
|
||||
? `"${rollbackTarget.recipeId || "manual"}" from ${formatTime(rollbackTarget.createdAt)}`
|
||||
: item.recipeId || "unknown"
|
||||
}
|
||||
</span>
|
||||
|
||||
@@ -3,9 +3,6 @@ import { api } from "../lib/api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -13,13 +10,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -31,7 +21,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { StatusLight, AgentOverview, Recipe, HistoryItem, ModelProfile } from "../lib/types";
|
||||
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
|
||||
import { RecipeCard } from "@/components/RecipeCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile } from "../lib/types";
|
||||
import { formatTime, formatBytes } from "@/lib/utils";
|
||||
|
||||
interface AgentGroup {
|
||||
identity: string;
|
||||
@@ -56,13 +50,19 @@ function groupAgents(agents: AgentOverview[]): AgentGroup[] {
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function Home({ onCook }: { onCook?: (recipeId: string, source?: string) => void }) {
|
||||
export function Home({
|
||||
onCook,
|
||||
showToast,
|
||||
}: {
|
||||
onCook?: (recipeId: string, source?: string) => void;
|
||||
showToast?: (message: string, type?: "success" | "error") => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<StatusLight | null>(null);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ available: boolean; latest?: string } | null>(null);
|
||||
const [agents, setAgents] = useState<AgentOverview[]>([]);
|
||||
const [agents, setAgents] = useState<AgentOverview[] | null>(null);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
||||
const [modelProfiles, setModelProfiles] = useState<ModelProfile[]>([]);
|
||||
const [savingModel, setSavingModel] = useState(false);
|
||||
const [backingUp, setBackingUp] = useState(false);
|
||||
@@ -70,11 +70,6 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
|
||||
// Create agent dialog
|
||||
const [showCreateAgent, setShowCreateAgent] = useState(false);
|
||||
const [newAgentId, setNewAgentId] = useState("");
|
||||
const [newAgentModel, setNewAgentModel] = useState("");
|
||||
const [newAgentIndependent, setNewAgentIndependent] = useState(false);
|
||||
const [creatingAgent, setCreatingAgent] = useState(false);
|
||||
const [createAgentError, setCreateAgentError] = useState("");
|
||||
|
||||
// Health status with grace period: retry quickly when unhealthy, then slow-poll
|
||||
const [statusSettled, setStatusSettled] = useState(false);
|
||||
@@ -91,7 +86,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
} else {
|
||||
setStatusSettled(true);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch((e) => console.error("Failed to fetch status:", e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,21 +96,28 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus, statusSettled]);
|
||||
|
||||
const refreshAgents = () => {
|
||||
api.listAgentsOverview().then(setAgents).catch(() => {});
|
||||
const refreshAgents = useCallback(() => {
|
||||
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAgents();
|
||||
// Auto-refresh agents every 15s
|
||||
const interval = setInterval(refreshAgents, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch((e) => console.error("Failed to load recipes:", e));
|
||||
}, []);
|
||||
|
||||
const refreshBackups = () => {
|
||||
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
|
||||
};
|
||||
useEffect(refreshAgents, []);
|
||||
useEffect(refreshBackups, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listHistory(5, 0).then((h) => setHistory(h.items)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch(() => {});
|
||||
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
|
||||
}, []);
|
||||
|
||||
// Match current global model value to a profile ID
|
||||
@@ -132,7 +134,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
return null;
|
||||
}, [status?.globalDefaultModel, modelProfiles]);
|
||||
|
||||
const agentGroups = useMemo(() => groupAgents(agents), [agents]);
|
||||
const agentGroups = useMemo(() => groupAgents(agents || []), [agents]);
|
||||
|
||||
// Heavy call: version + update check, deferred
|
||||
useEffect(() => {
|
||||
@@ -145,35 +147,15 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
latest: s.openclawUpdate.latestVersion,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch((e) => console.error("Failed to fetch system status:", e));
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleCreateAgent = () => {
|
||||
const id = newAgentId.trim();
|
||||
if (!id) {
|
||||
setCreateAgentError("Agent ID is required");
|
||||
return;
|
||||
}
|
||||
setCreatingAgent(true);
|
||||
setCreateAgentError("");
|
||||
api.createAgent(id, newAgentModel || undefined, newAgentIndependent || undefined)
|
||||
.then(() => {
|
||||
setShowCreateAgent(false);
|
||||
setNewAgentId("");
|
||||
setNewAgentModel("");
|
||||
setNewAgentIndependent(false);
|
||||
refreshAgents();
|
||||
})
|
||||
.catch((e) => setCreateAgentError(String(e)))
|
||||
.finally(() => setCreatingAgent(false));
|
||||
};
|
||||
|
||||
const handleDeleteAgent = (agentId: string) => {
|
||||
api.deleteAgent(agentId)
|
||||
.then(() => refreshAgents())
|
||||
.catch(() => {});
|
||||
.catch((e) => showToast?.(String(e), "error"));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -223,7 +205,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
setBackupMessage(`Backup: ${info.name}`);
|
||||
api.openUrl("https://github.com/openclaw/openclaw/releases");
|
||||
})
|
||||
.catch(() => setBackupMessage("Backup failed"))
|
||||
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
|
||||
.finally(() => setBackingUp(false));
|
||||
}}
|
||||
>
|
||||
@@ -246,12 +228,12 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
api.setGlobalModel(val === "__none__" ? null : val)
|
||||
.then(() => api.getStatusLight())
|
||||
.then(setStatus)
|
||||
.catch(() => {})
|
||||
.catch((e) => showToast?.(String(e), "error"))
|
||||
.finally(() => setSavingModel(false));
|
||||
}}
|
||||
disabled={savingModel}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectTrigger size="sm" className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -272,14 +254,19 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agents Overview — grouped by identity */}
|
||||
{/* Agents Overview -- grouped by identity */}
|
||||
<div className="flex items-center justify-between mt-6 mb-3">
|
||||
<h3 className="text-lg font-semibold">Agents</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowCreateAgent(true)}>
|
||||
+ New Agent
|
||||
</Button>
|
||||
</div>
|
||||
{agentGroups.length === 0 ? (
|
||||
{agents === null ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : agentGroups.length === 0 ? (
|
||||
<p className="text-muted-foreground">No agents found.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -351,47 +338,125 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
|
||||
{recipes.map((recipe) => (
|
||||
<Card
|
||||
<RecipeCard
|
||||
key={recipe.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => onCook?.(recipe.id)}
|
||||
>
|
||||
<CardContent>
|
||||
<strong>{recipe.name}</strong>
|
||||
<div className="text-sm text-muted-foreground mt-1.5">
|
||||
{recipe.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{recipe.steps.length} step{recipe.steps.length !== 1 ? "s" : ""} · {recipe.difficulty}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
recipe={recipe}
|
||||
onCook={() => onCook?.(recipe.id)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3">Recent Activity</h3>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-muted-foreground">No recent activity.</p>
|
||||
{/* Backups */}
|
||||
<div className="flex items-center justify-between mt-6 mb-3">
|
||||
<h3 className="text-lg font-semibold">Backups</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={backingUp}
|
||||
onClick={() => {
|
||||
setBackingUp(true);
|
||||
setBackupMessage("");
|
||||
api.backupBeforeUpgrade()
|
||||
.then((info) => {
|
||||
setBackupMessage(`Created backup: ${info.name}`);
|
||||
refreshBackups();
|
||||
})
|
||||
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
|
||||
.finally(() => setBackingUp(false));
|
||||
}}
|
||||
>
|
||||
{backingUp ? "Creating..." : "Create Backup"}
|
||||
</Button>
|
||||
</div>
|
||||
{backupMessage && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
|
||||
)}
|
||||
{backups === null ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No backups available.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{history.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="flex justify-between items-center">
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<Card key={backup.name}>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{item.recipeId || "manual change"}</span>
|
||||
<span className="text-sm text-muted-foreground ml-2.5">
|
||||
{item.source}
|
||||
</span>
|
||||
<div className="font-medium text-sm">{backup.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{item.canRollback && (
|
||||
<span className="text-xs text-muted-foreground">rollback available</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.createdAt}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => api.openUrl(backup.path)}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
Restore
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
api.restoreFromBackup(backup.name)
|
||||
.then((msg) => setBackupMessage(msg))
|
||||
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete backup "{backup.name}". This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
api.deleteBackup(backup.name)
|
||||
.then(() => {
|
||||
setBackupMessage(`Deleted backup "${backup.name}"`);
|
||||
refreshBackups();
|
||||
})
|
||||
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -400,66 +465,12 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
|
||||
)}
|
||||
|
||||
{/* Create Agent Dialog */}
|
||||
<Dialog open={showCreateAgent} onOpenChange={setShowCreateAgent}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. my-agent"
|
||||
value={newAgentId}
|
||||
onChange={(e) => setNewAgentId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={newAgentModel || "__default__"}
|
||||
onValueChange={(val) => setNewAgentModel(val === "__default__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<span className="text-muted-foreground">use global default</span>
|
||||
</SelectItem>
|
||||
{modelProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.provider}/{p.model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="independent-agent"
|
||||
checked={newAgentIndependent}
|
||||
onCheckedChange={(checked) => setNewAgentIndependent(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="independent-agent">Independent agent (separate workspace)</Label>
|
||||
</div>
|
||||
{createAgentError && (
|
||||
<p className="text-sm text-destructive">{createAgentError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateAgent(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateAgent} disabled={creatingAgent}>
|
||||
{creatingAgent ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreateAgentDialog
|
||||
open={showCreateAgent}
|
||||
onOpenChange={setShowCreateAgent}
|
||||
modelProfiles={modelProfiles}
|
||||
onCreated={() => refreshAgents()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Recipes({
|
||||
setLoadedSource(value || undefined);
|
||||
setRecipes(r);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((e) => console.error("Failed to load recipes:", e))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { BackupInfo, ModelCatalogProvider, ModelProfile, ProviderAuthSuggestion, ResolvedApiKey } from "@/lib/types";
|
||||
import type { ModelCatalogProvider, ModelProfile, ProviderAuthSuggestion, ResolvedApiKey } from "@/lib/types";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -113,18 +113,6 @@ function AutocompleteField({
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
const [profiles, setProfiles] = useState<ModelProfile[]>([]);
|
||||
const [catalog, setCatalog] = useState<ModelCatalogProvider[]>([]);
|
||||
@@ -132,27 +120,20 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
const [form, setForm] = useState<ProfileForm>(emptyForm());
|
||||
const [message, setMessage] = useState("");
|
||||
const [authSuggestion, setAuthSuggestion] = useState<ProviderAuthSuggestion | null>(null);
|
||||
const [backups, setBackups] = useState<BackupInfo[]>([]);
|
||||
const [backupMessage, setBackupMessage] = useState("");
|
||||
|
||||
const [catalogRefreshed, setCatalogRefreshed] = useState(false);
|
||||
|
||||
// Load profiles and API keys immediately (fast)
|
||||
const refreshProfiles = () => {
|
||||
api.listModelProfiles().then(setProfiles).catch(() => {});
|
||||
api.resolveApiKeys().then(setApiKeys).catch(() => {});
|
||||
api.listModelProfiles().then(setProfiles).catch((e) => console.error("Failed to load profiles:", e));
|
||||
api.resolveApiKeys().then(setApiKeys).catch((e) => console.error("Failed to resolve API keys:", e));
|
||||
};
|
||||
|
||||
useEffect(refreshProfiles, []);
|
||||
|
||||
// Load catalog from cache instantly (no CLI calls)
|
||||
useEffect(() => {
|
||||
api.getCachedModelCatalog().then(setCatalog).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load backups
|
||||
useEffect(() => {
|
||||
api.listBackups().then(setBackups).catch(() => {});
|
||||
api.getCachedModelCatalog().then(setCatalog).catch((e) => console.error("Failed to load model catalog:", e));
|
||||
}, []);
|
||||
|
||||
// Refresh catalog from CLI when user focuses provider/model input
|
||||
@@ -161,7 +142,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
setCatalogRefreshed(true);
|
||||
api.refreshModelCatalog().then((fresh) => {
|
||||
if (fresh.length > 0) setCatalog(fresh);
|
||||
}).catch(() => {});
|
||||
}).catch((e) => console.error("Failed to refresh model catalog:", e));
|
||||
};
|
||||
|
||||
// Check for existing auth when provider changes (only for new profiles)
|
||||
@@ -172,7 +153,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
}
|
||||
api.resolveProviderAuth(form.provider)
|
||||
.then(setAuthSuggestion)
|
||||
.catch(() => setAuthSuggestion(null));
|
||||
.catch((e) => { console.error("Failed to resolve provider auth:", e); setAuthSuggestion(null); });
|
||||
}, [form.provider, form.id]);
|
||||
|
||||
const maskedKeyMap = useMemo(() => {
|
||||
@@ -216,7 +197,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
refreshProfiles();
|
||||
onDataChange?.();
|
||||
})
|
||||
.catch(() => setMessage("Save failed"));
|
||||
.catch((e) => setMessage(`Save failed: ${e}`));
|
||||
};
|
||||
|
||||
const editProfile = (profile: ModelProfile) => {
|
||||
@@ -242,7 +223,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
refreshProfiles();
|
||||
onDataChange?.();
|
||||
})
|
||||
.catch(() => setMessage("Delete failed"));
|
||||
.catch((e) => setMessage(`Delete failed: ${e}`));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -460,97 +441,6 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
|
||||
{message && (
|
||||
<p className="text-sm text-muted-foreground mt-3">{message}</p>
|
||||
)}
|
||||
|
||||
{/* Backups */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3">Backups</h3>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No backups available.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<Card key={backup.name}>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{backup.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{backup.createdAt} — {formatBytes(backup.sizeBytes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => api.openUrl(backup.path)}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
Restore
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
api.restoreFromBackup(backup.name)
|
||||
.then((msg) => setBackupMessage(msg))
|
||||
.catch(() => setBackupMessage("Restore failed"));
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete backup "{backup.name}". This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
api.deleteBackup(backup.name)
|
||||
.then(() => {
|
||||
setBackupMessage(`Deleted backup "${backup.name}"`);
|
||||
api.listBackups().then(setBackups).catch(() => {});
|
||||
})
|
||||
.catch(() => setBackupMessage("Delete failed"));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{backupMessage && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{backupMessage}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user