Backend:
- room_ai_list: batch-fetch models, skip entries where model_name
cannot be resolved (instead of falling back to "AI {uid}")
- room_ai_upsert: return None for model_name when lookup fails
(instead of "AI {uid}")
Frontend:
- room-context: discard configs with missing modelName after retries
- DiscordMemberList: filter out configs without modelName
- MessageInput: filter out configs without modelName
- RoomSettingsPanel: prefer model_name from API, fallback to
availableModels lookup, never render raw UID
- RoomAiTasksPanel: fix broken id/name mapping (was cfg.id/cfg.name
which don't exist), filter out configs without model_name
272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
import { useState, useCallback, useEffect, memo } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Timer, X, Plus, Loader2, Bot } from 'lucide-react';
|
|
import { client } from '@/client/client.gen';
|
|
|
|
interface AiConfig {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
system_prompt?: string;
|
|
}
|
|
|
|
interface RoomAiTasksPanelProps {
|
|
roomId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const RoomAiTasksPanel = memo(function RoomAiTasksPanel({ roomId, onClose }: RoomAiTasksPanelProps) {
|
|
const [aiConfigs, setAiConfigs] = useState<AiConfig[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
|
|
const loadAiConfigs = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const resp = await client.get({
|
|
url: `/api/rooms/${roomId}/ai`,
|
|
});
|
|
const data = (resp.data as any)?.data as any[] | undefined;
|
|
if (data) {
|
|
// Only include configs that have a valid model_name — never expose UID
|
|
setAiConfigs(data
|
|
.filter((cfg: any) => !!cfg.model_name)
|
|
.map((cfg: any) => ({
|
|
id: cfg.model,
|
|
name: cfg.model_name,
|
|
enabled: cfg.enabled !== false,
|
|
system_prompt: cfg.system_prompt,
|
|
})));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load AI configs:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [roomId]);
|
|
|
|
useEffect(() => {
|
|
loadAiConfigs();
|
|
}, [loadAiConfigs]);
|
|
|
|
const handleAddConfig = useCallback(async (modelName: string, systemPrompt: string) => {
|
|
try {
|
|
await client.put({
|
|
url: `/api/rooms/${roomId}/ai`,
|
|
body: {
|
|
name: modelName,
|
|
system_prompt: systemPrompt,
|
|
},
|
|
});
|
|
await loadAiConfigs();
|
|
setShowAddForm(false);
|
|
} catch (err) {
|
|
console.error('Failed to add AI config:', err);
|
|
}
|
|
}, [roomId, loadAiConfigs]);
|
|
|
|
const handleDeleteConfig = useCallback(async (configId: string) => {
|
|
try {
|
|
await client.delete({
|
|
url: `/api/rooms/${roomId}/ai/${configId}`,
|
|
});
|
|
await loadAiConfigs();
|
|
} catch (err) {
|
|
console.error('Failed to delete AI config:', err);
|
|
}
|
|
}, [roomId, loadAiConfigs]);
|
|
|
|
const handleToggleEnabled = useCallback(async (configId: string, enabled: boolean) => {
|
|
try {
|
|
const config = aiConfigs.find((c) => c.id === configId);
|
|
if (!config) return;
|
|
|
|
await client.put({
|
|
url: `/api/rooms/${roomId}/ai`,
|
|
body: {
|
|
id: configId,
|
|
name: config.name,
|
|
system_prompt: config.system_prompt,
|
|
enabled,
|
|
},
|
|
});
|
|
await loadAiConfigs();
|
|
} catch (err) {
|
|
console.error('Failed to toggle AI enabled:', err);
|
|
}
|
|
}, [roomId, aiConfigs, loadAiConfigs]);
|
|
|
|
return (
|
|
<div className="flex h-full w-72 shrink-0 flex-col border-l border-border bg-background">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-border p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Timer className="h-5 w-5 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold text-foreground">AI Tasks</h2>
|
|
{aiConfigs.length > 0 && (
|
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
|
{aiConfigs.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setShowAddForm((v) => !v)}
|
|
title="Add AI"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add AI Form */}
|
|
{showAddForm && (
|
|
<AddAiConfigForm
|
|
onSubmit={handleAddConfig}
|
|
onCancel={() => setShowAddForm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* AI Configs List */}
|
|
<ScrollArea className="flex-1">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : aiConfigs.length === 0 ? (
|
|
<div className="py-8 text-center">
|
|
<Bot className="mx-auto mb-3 h-12 w-12 text-muted-foreground/50" />
|
|
<p className="text-sm text-muted-foreground">No AI models configured</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3"
|
|
onClick={() => setShowAddForm(true)}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
Add AI Model
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 p-3">
|
|
{aiConfigs.map((config) => (
|
|
<AiConfigCard
|
|
key={config.id}
|
|
config={config}
|
|
onToggle={(enabled) => handleToggleEnabled(config.id, enabled)}
|
|
onDelete={() => handleDeleteConfig(config.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// AI Config Card Component
|
|
interface AiConfigCardProps {
|
|
config: AiConfig;
|
|
onToggle: (enabled: boolean) => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const AiConfigCard = memo(function AiConfigCard({ config, onToggle, onDelete }: AiConfigCardProps) {
|
|
return (
|
|
<div className="flex items-start gap-2 rounded-lg border border-border/50 p-2">
|
|
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-medium text-foreground">{config.name}</p>
|
|
<Badge
|
|
variant={config.enabled ? 'default' : 'secondary'}
|
|
className="h-4 px-1 text-[10px]"
|
|
>
|
|
{config.enabled ? 'On' : 'Off'}
|
|
</Badge>
|
|
</div>
|
|
<Switch
|
|
checked={config.enabled}
|
|
onCheckedChange={onToggle}
|
|
aria-label={`Toggle ${config.name}`}
|
|
/>
|
|
</div>
|
|
{config.system_prompt && (
|
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
|
{config.system_prompt}
|
|
</p>
|
|
)}
|
|
<div className="mt-2 flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-[10px] text-destructive"
|
|
onClick={onDelete}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// Add AI Config Form
|
|
interface AddAiConfigFormProps {
|
|
onSubmit: (modelName: string, systemPrompt: string) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const AddAiConfigForm = memo(function AddAiConfigForm({ onSubmit, onCancel }: AddAiConfigFormProps) {
|
|
const [modelName, setModelName] = useState('');
|
|
const [systemPrompt, setSystemPrompt] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!modelName.trim()) return;
|
|
setIsSubmitting(true);
|
|
onSubmit(modelName.trim(), systemPrompt.trim());
|
|
setIsSubmitting(false);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="border-b border-border p-3">
|
|
<div className="space-y-2">
|
|
<input
|
|
type="text"
|
|
value={modelName}
|
|
onChange={(e) => setModelName(e.target.value)}
|
|
placeholder="Model name (e.g., gpt-4o)"
|
|
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
<Textarea
|
|
value={systemPrompt}
|
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
|
placeholder="System prompt (optional)"
|
|
className="min-h-[60px] resize-none text-sm"
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" size="sm" disabled={!modelName.trim() || isSubmitting}>
|
|
{isSubmitting ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
});
|