gitdataai/src/components/room/RoomAiTasksPanel.tsx
ZhenYi bc1bdd8491 fix(room): never expose AI model UID to frontend
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
2026-04-28 23:21:45 +08:00

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>
);
});