Files
clawpal/src/pages/Settings.tsx
zhixian 2208f33177 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>
2026-02-18 02:10:45 +09:00

447 lines
15 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent } from "react";
import { api } from "@/lib/api";
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";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
type ProfileForm = {
id: string;
provider: string;
model: string;
apiKey: string;
useCustomUrl: boolean;
baseUrl: string;
enabled: boolean;
};
function emptyForm(): ProfileForm {
return {
id: "",
provider: "",
model: "",
apiKey: "",
useCustomUrl: false,
baseUrl: "",
enabled: true,
};
}
function AutocompleteField({
value,
onChange,
onFocus,
options,
placeholder,
}: {
value: string;
onChange: (val: string) => void;
onFocus?: () => void;
options: { value: string; label: string }[];
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const filtered = options.filter(
(o) =>
!value ||
o.value.toLowerCase().includes(value.toLowerCase()) ||
o.label.toLowerCase().includes(value.toLowerCase()),
);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={wrapperRef} className="relative">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => {
setOpen(true);
onFocus?.();
}}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
/>
{open && filtered.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-md max-h-[200px] overflow-y-auto">
{filtered.map((option) => (
<div
key={option.value}
className="px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
onChange(option.value);
setOpen(false);
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
);
}
export function Settings({ onDataChange }: { onDataChange?: () => void }) {
const [profiles, setProfiles] = useState<ModelProfile[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogProvider[]>([]);
const [apiKeys, setApiKeys] = useState<ResolvedApiKey[]>([]);
const [form, setForm] = useState<ProfileForm>(emptyForm());
const [message, setMessage] = useState("");
const [authSuggestion, setAuthSuggestion] = useState<ProviderAuthSuggestion | null>(null);
const [catalogRefreshed, setCatalogRefreshed] = useState(false);
// Load profiles and API keys immediately (fast)
const refreshProfiles = () => {
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((e) => console.error("Failed to load model catalog:", e));
}, []);
// Refresh catalog from CLI when user focuses provider/model input
const ensureCatalog = () => {
if (catalogRefreshed) return;
setCatalogRefreshed(true);
api.refreshModelCatalog().then((fresh) => {
if (fresh.length > 0) setCatalog(fresh);
}).catch((e) => console.error("Failed to refresh model catalog:", e));
};
// Check for existing auth when provider changes (only for new profiles)
useEffect(() => {
if (form.id || !form.provider.trim()) {
setAuthSuggestion(null);
return;
}
api.resolveProviderAuth(form.provider)
.then(setAuthSuggestion)
.catch((e) => { console.error("Failed to resolve provider auth:", e); setAuthSuggestion(null); });
}, [form.provider, form.id]);
const maskedKeyMap = useMemo(() => {
const map = new Map<string, string>();
for (const entry of apiKeys) {
map.set(entry.profileId, entry.maskedKey);
}
return map;
}, [apiKeys]);
const modelCandidates = useMemo(() => {
const found = catalog.find((c) => c.provider === form.provider);
return found?.models || [];
}, [catalog, form.provider]);
const upsert = (event: FormEvent) => {
event.preventDefault();
if (!form.provider || !form.model) {
setMessage("Provider and Model are required");
return;
}
if (!form.apiKey && !form.id && !authSuggestion?.hasKey) {
setMessage("API Key is required");
return;
}
const profileData: ModelProfile = {
id: form.id || "",
name: `${form.provider}/${form.model}`,
provider: form.provider,
model: form.model,
authRef: (!form.apiKey && authSuggestion?.authRef) ? authSuggestion.authRef : "",
apiKey: form.apiKey || undefined,
baseUrl: form.useCustomUrl && form.baseUrl ? form.baseUrl : undefined,
enabled: form.enabled,
};
api
.upsertModelProfile(profileData)
.then(() => {
setMessage("Profile saved");
setForm(emptyForm());
refreshProfiles();
onDataChange?.();
})
.catch((e) => setMessage(`Save failed: ${e}`));
};
const editProfile = (profile: ModelProfile) => {
setForm({
id: profile.id,
provider: profile.provider,
model: profile.model,
apiKey: "",
useCustomUrl: !!profile.baseUrl,
baseUrl: profile.baseUrl || "",
enabled: profile.enabled,
});
};
const deleteProfile = (id: string) => {
api
.deleteModelProfile(id)
.then(() => {
setMessage("Profile deleted");
if (form.id === id) {
setForm(emptyForm());
}
refreshProfiles();
onDataChange?.();
})
.catch((e) => setMessage(`Delete failed: ${e}`));
};
return (
<section>
<h2 className="text-2xl font-bold mb-4">Settings</h2>
<p className="text-sm text-muted-foreground mb-4">
For OAuth-based providers (GitHub Copilot, etc.), use the CLI:
<code className="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs">openclaw models auth login</code>
or
<code className="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs">openclaw models auth login-github-copilot</code>.
Profiles created via CLI will appear in the list on the right.
</p>
{/* ---- Model Profiles ---- */}
<div className="grid grid-cols-2 gap-3 items-start">
{/* Create / Edit form */}
<Card>
<CardHeader>
<CardTitle>{form.id ? "Edit Profile" : "Add Profile"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={upsert} className="space-y-4">
<div className="space-y-1.5">
<Label>Provider</Label>
<AutocompleteField
value={form.provider}
onChange={(val) =>
setForm((p) => ({ ...p, provider: val, model: "" }))
}
onFocus={ensureCatalog}
options={catalog.map((c) => ({
value: c.provider,
label: c.provider,
}))}
placeholder="e.g. openai"
/>
</div>
<div className="space-y-1.5">
<Label>Model</Label>
<AutocompleteField
value={form.model}
onChange={(val) =>
setForm((p) => ({ ...p, model: val }))
}
onFocus={ensureCatalog}
options={modelCandidates.map((m) => ({
value: m.id,
label: m.name || m.id,
}))}
placeholder="e.g. gpt-4o"
/>
</div>
<div className="space-y-1.5">
<Label>API Key</Label>
<Input
type="password"
placeholder={form.id ? "(unchanged if empty)" : authSuggestion?.hasKey ? "(optional — key already available)" : "sk-..."}
value={form.apiKey}
onChange={(e) =>
setForm((p) => ({ ...p, apiKey: e.target.value }))
}
/>
{!form.id && authSuggestion?.hasKey && (
<p className="text-xs text-muted-foreground">
Key available via {authSuggestion.source}. Leave empty to reuse it.
</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
id="custom-url"
checked={form.useCustomUrl}
onCheckedChange={(checked) =>
setForm((p) => ({ ...p, useCustomUrl: checked === true }))
}
/>
<Label htmlFor="custom-url">Custom Base URL</Label>
</div>
{form.useCustomUrl && (
<div className="space-y-1.5">
<Label>Base URL</Label>
<Input
placeholder="e.g. https://api.openai.com/v1"
value={form.baseUrl}
onChange={(e) =>
setForm((p) => ({ ...p, baseUrl: e.target.value }))
}
/>
</div>
)}
<div className="flex gap-2 mt-2">
<Button type="submit">Save</Button>
{form.id && (
<>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete profile?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the profile "{form.provider}/{form.model}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteProfile(form.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
type="button"
variant="outline"
onClick={() => setForm(emptyForm())}
>
Cancel
</Button>
</>
)}
</div>
</form>
</CardContent>
</Card>
{/* Profiles list */}
<Card>
<CardHeader>
<CardTitle>Model Profiles</CardTitle>
</CardHeader>
<CardContent>
{profiles.length === 0 && (
<p className="text-muted-foreground">No model profiles yet.</p>
)}
<div className="grid gap-2">
{profiles.map((profile) => (
<div
key={profile.id}
className="border border-border p-2.5 rounded-lg"
>
<div className="flex justify-between items-center">
<strong>{profile.provider}/{profile.model}</strong>
{profile.enabled ? (
<Badge className="bg-blue-100 text-blue-700 border-0">
enabled
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-0">
disabled
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
API Key: {maskedKeyMap.get(profile.id) || "..."}
</div>
{profile.baseUrl && (
<div className="text-sm text-muted-foreground mt-0.5">
URL: {profile.baseUrl}
</div>
)}
<div className="flex gap-1.5 mt-1.5">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => editProfile(profile)}
>
Edit
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" type="button">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete profile?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the profile "{profile.provider}/{profile.model}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteProfile(profile.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{message && (
<p className="text-sm text-muted-foreground mt-3">{message}</p>
)}
</section>
);
}