- git: clean up hook pool worker, commit sync, HTTP rate limiting - billing: tighten workspace/project/agent billing logic - fctool: add project boards and issues management tools - api/ws: minor room WebSocket protocol adjustments - frontend: add RoomSettingsPanel component
749 lines
30 KiB
TypeScript
749 lines
30 KiB
TypeScript
import React, { memo, useState, useEffect, useCallback, useRef } from 'react';
|
|
import type { RoomResponse, RoomAiResponse, RoomAiUpsertRequest, ModelWithPricingResponse, ModelListResponse } from '@/client';
|
|
import { aiList, aiUpsert, aiDelete } from '@/client';
|
|
import { client } from '@/client/client.gen';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Loader2,
|
|
Plus,
|
|
Trash2,
|
|
Bot,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Search,
|
|
ArrowLeft,
|
|
Cpu,
|
|
Lock,
|
|
DollarSign,
|
|
Zap,
|
|
BookOpen,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
interface RoomSettingsPanelProps {
|
|
room: RoomResponse;
|
|
onUpdate: (name: string, isPublic: boolean) => Promise<void>;
|
|
onClose: () => void;
|
|
isPending: boolean;
|
|
}
|
|
|
|
type CatalogModel = ModelWithPricingResponse;
|
|
type CatalogResponse = ModelListResponse;
|
|
|
|
function formatContextLength(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
|
|
return String(n);
|
|
}
|
|
|
|
function formatPrice(price?: string | null): string {
|
|
if (!price) return 'N/A';
|
|
const n = parseFloat(price);
|
|
if (isNaN(n)) return price;
|
|
if (n === 0) return 'Free';
|
|
return `$${n.toFixed(2)}`;
|
|
}
|
|
|
|
const CAPABILITY_COLORS: Record<string, string> = {
|
|
chat: '#3b82f6',
|
|
code: '#8b5cf6',
|
|
completion: '#06b6d4',
|
|
embedding: '#f59e0b',
|
|
vision: '#ec4899',
|
|
reasoning: '#10b981',
|
|
};
|
|
|
|
const MODALITY_ICONS: Record<string, string> = {
|
|
text: 'T',
|
|
multimodal: 'M',
|
|
image: 'I',
|
|
audio: 'A',
|
|
};
|
|
|
|
export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|
room,
|
|
onUpdate,
|
|
onClose,
|
|
isPending,
|
|
}: RoomSettingsPanelProps) {
|
|
const [name, setName] = useState(room.room_name ?? '');
|
|
const [isPublic, setIsPublic] = useState(!!room.public);
|
|
|
|
useEffect(() => {
|
|
setName(room.room_name ?? '');
|
|
setIsPublic(!!room.public);
|
|
}, [room.id, room.room_name, room.public]);
|
|
|
|
// AI section state
|
|
const [aiConfigs, setAiConfigs] = useState<RoomAiResponse[]>([]);
|
|
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
|
const [showAddPanel, setShowAddPanel] = useState(false);
|
|
|
|
// Catalog state
|
|
const [catalogModels, setCatalogModels] = useState<CatalogModel[]>([]);
|
|
const [catalogLoading, setCatalogLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [catalogPage, setCatalogPage] = useState(1);
|
|
const [catalogTotal, setCatalogTotal] = useState(0);
|
|
const [catalogPerPage] = useState(20);
|
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
// Selected model + config form state
|
|
const [selectedModel, setSelectedModel] = useState<CatalogModel | null>(null);
|
|
const [temperature, setTemperature] = useState('');
|
|
const [maxTokens, setMaxTokens] = useState('');
|
|
const [historyLimit, setHistoryLimit] = useState('');
|
|
const [systemPrompt, setSystemPrompt] = useState('');
|
|
const [useExact, setUseExact] = useState(false);
|
|
const [think, setThink] = useState(false);
|
|
const [stream, setStream] = useState(true);
|
|
const [agentType, setAgentType] = useState('chat');
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [isAddingAi, setIsAddingAi] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim()) return;
|
|
try {
|
|
await onUpdate(name.trim(), isPublic);
|
|
toast.success('Room updated');
|
|
onClose();
|
|
} catch {
|
|
toast.error('Failed to update room');
|
|
}
|
|
};
|
|
|
|
// Load AI configs
|
|
const loadAiConfigs = useCallback(async () => {
|
|
setAiConfigsLoading(true);
|
|
try {
|
|
const resp = await aiList({ path: { room_id: room.id } });
|
|
const inner = resp.data as { data?: RoomAiResponse[] } | undefined;
|
|
setAiConfigs(Array.isArray(inner?.data) ? inner.data : []);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setAiConfigsLoading(false);
|
|
}
|
|
}, [room.id]);
|
|
|
|
useEffect(() => {
|
|
loadAiConfigs();
|
|
}, [loadAiConfigs]);
|
|
|
|
// Load catalog models
|
|
const loadCatalog = useCallback(async (search: string, page: number) => {
|
|
setCatalogLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('page', String(page));
|
|
params.set('per_page', String(catalogPerPage));
|
|
if (search) params.set('search', search);
|
|
|
|
const resp = await client.get({
|
|
url: `/api/agents/models/catalog?${params.toString()}`,
|
|
});
|
|
const data = (resp.data as { data?: CatalogResponse })?.data;
|
|
if (data) {
|
|
setCatalogModels(data.data ?? []);
|
|
setCatalogTotal(data.total ?? 0);
|
|
}
|
|
} catch {
|
|
toast.error('Failed to load models');
|
|
} finally {
|
|
setCatalogLoading(false);
|
|
}
|
|
}, [catalogPerPage]);
|
|
|
|
// Debounced search
|
|
useEffect(() => {
|
|
if (!showAddPanel) return;
|
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
searchTimerRef.current = setTimeout(() => {
|
|
setCatalogPage(1);
|
|
loadCatalog(searchQuery, 1);
|
|
}, 300);
|
|
return () => {
|
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
};
|
|
}, [searchQuery, showAddPanel, loadCatalog]);
|
|
|
|
useEffect(() => {
|
|
if (showAddPanel) {
|
|
loadCatalog(searchQuery, catalogPage);
|
|
}
|
|
}, [catalogPage, showAddPanel]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const openAddPanel = () => {
|
|
setSelectedModel(null);
|
|
setSearchQuery('');
|
|
setCatalogPage(1);
|
|
setTemperature('');
|
|
setMaxTokens('');
|
|
setHistoryLimit('');
|
|
setSystemPrompt('');
|
|
setUseExact(false);
|
|
setThink(false);
|
|
setStream(true);
|
|
setAgentType('chat');
|
|
setShowAdvanced(false);
|
|
setShowAddPanel(true);
|
|
};
|
|
|
|
const handleAddAi = async () => {
|
|
if (!selectedModel) return;
|
|
setIsAddingAi(true);
|
|
try {
|
|
const body: RoomAiUpsertRequest = {
|
|
model: selectedModel.id,
|
|
temperature: temperature ? parseFloat(temperature) : undefined,
|
|
max_tokens: maxTokens ? parseInt(maxTokens) : undefined,
|
|
history_limit: historyLimit ? parseInt(historyLimit) : undefined,
|
|
system_prompt: systemPrompt || undefined,
|
|
use_exact: useExact,
|
|
think,
|
|
stream,
|
|
agent_type: agentType || undefined,
|
|
};
|
|
await aiUpsert({ path: { room_id: room.id }, body });
|
|
toast.success(`${selectedModel.name} added to room`);
|
|
setShowAddPanel(false);
|
|
setSelectedModel(null);
|
|
loadAiConfigs();
|
|
} catch {
|
|
toast.error('Failed to add AI model');
|
|
} finally {
|
|
setIsAddingAi(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAi = async (modelId: string) => {
|
|
try {
|
|
await aiDelete({ path: { room_id: room.id, model_id: modelId } });
|
|
toast.success('AI model removed');
|
|
setAiConfigs((prev) => prev.filter((c) => c.model !== modelId));
|
|
} catch {
|
|
toast.error('Failed to remove AI model');
|
|
}
|
|
};
|
|
|
|
const totalPages = Math.max(1, Math.ceil(catalogTotal / catalogPerPage));
|
|
|
|
// ── Add Panel View ──
|
|
if (showAddPanel) {
|
|
return (
|
|
<aside
|
|
className="flex flex-col h-full"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)' }}
|
|
>
|
|
<header
|
|
className="flex h-12 items-center gap-2 border-b px-4 shrink-0"
|
|
style={{ borderColor: 'var(--room-border)' }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setShowAddPanel(false); setSelectedModel(null); }}
|
|
className="p-1 rounded hover:opacity-70"
|
|
style={{ color: 'var(--room-text)' }}
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
<Bot className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
|
<p className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
|
{selectedModel ? `Configure ${selectedModel.name}` : 'Add AI Model'}
|
|
</p>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!selectedModel ? (
|
|
/* ── Model Browser ── */
|
|
<div className="p-3 space-y-3">
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5"
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
/>
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search models..."
|
|
className="pl-8 h-8 text-sm"
|
|
style={{
|
|
background: 'var(--room-bg)',
|
|
borderColor: 'var(--room-border)',
|
|
color: 'var(--room-text)',
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Model list */}
|
|
{catalogLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
|
</div>
|
|
) : catalogModels.length === 0 ? (
|
|
<p className="text-xs text-center py-8" style={{ color: 'var(--room-text-muted)' }}>
|
|
No models found
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{catalogModels.map((model) => {
|
|
const capColor = CAPABILITY_COLORS[model.capability] ?? '#6b7280';
|
|
const alreadyAdded = aiConfigs.some((c) => c.model === model.id);
|
|
return (
|
|
<button
|
|
key={model.id}
|
|
type="button"
|
|
disabled={alreadyAdded}
|
|
className="w-full text-left rounded-lg p-2.5 transition-colors disabled:opacity-40"
|
|
style={{
|
|
border: `1px solid var(--room-border)`,
|
|
background: 'var(--room-bg)',
|
|
}}
|
|
onClick={() => setSelectedModel(model)}
|
|
onMouseEnter={(e) => {
|
|
if (!alreadyAdded) (e.currentTarget as HTMLElement).style.background = 'var(--room-hover, rgba(255,255,255,0.03))';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background = 'var(--room-bg)';
|
|
}}
|
|
>
|
|
{/* Row 1: Name + badges */}
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-sm font-medium truncate" style={{ color: 'var(--room-text)' }}>
|
|
{model.name}
|
|
</span>
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[9px] font-medium uppercase"
|
|
style={{ background: `${capColor}18`, color: capColor }}
|
|
>
|
|
{model.capability}
|
|
</span>
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[9px] font-medium"
|
|
style={{ background: 'rgba(107,114,128,0.1)', color: '#9ca3af' }}
|
|
>
|
|
{MODALITY_ICONS[model.modality] ?? '·'} {model.modality}
|
|
</span>
|
|
{model.is_open_source && (
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[9px]"
|
|
style={{ background: 'rgba(16,185,129,0.1)', color: '#10b981' }}
|
|
>
|
|
OSS
|
|
</span>
|
|
)}
|
|
{alreadyAdded && (
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[9px] ml-auto"
|
|
style={{ background: 'rgba(107,114,128,0.1)', color: '#9ca3af' }}
|
|
>
|
|
Added
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 2: Stats */}
|
|
<div className="flex items-center gap-3 mt-1.5 text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
|
<span className="flex items-center gap-0.5">
|
|
<BookOpen className="h-3 w-3" />
|
|
{formatContextLength(model.context_length)}
|
|
</span>
|
|
{model.max_output_tokens && (
|
|
<span className="flex items-center gap-0.5">
|
|
<Zap className="h-3 w-3" />
|
|
{formatContextLength(model.max_output_tokens)} out
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-0.5">
|
|
<DollarSign className="h-3 w-3" />
|
|
{formatPrice(model.input_price)} / {formatPrice(model.output_price)}
|
|
{model.currency && model.input_price && (
|
|
<span className="opacity-60"> {model.currency}</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
disabled={catalogPage <= 1}
|
|
onClick={() => setCatalogPage((p) => Math.max(1, p - 1))}
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>
|
|
{catalogPage} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
disabled={catalogPage >= totalPages}
|
|
onClick={() => setCatalogPage((p) => Math.min(totalPages, p + 1))}
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* ── Selected Model: Config Form ── */
|
|
<div className="p-4 space-y-4">
|
|
{/* Model info card */}
|
|
<div
|
|
className="rounded-lg p-3 space-y-2"
|
|
style={{ border: '1px solid var(--room-border)', background: 'rgba(255,255,255,0.02)' }}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
|
{selectedModel.name}
|
|
</span>
|
|
{selectedModel.is_open_source && (
|
|
<Lock className="h-3 w-3" style={{ color: '#10b981' }} />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
|
<span className="flex items-center gap-1">
|
|
<Cpu className="h-3 w-3" />
|
|
{selectedModel.capability} / {selectedModel.modality}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<BookOpen className="h-3 w-3" />
|
|
{formatContextLength(selectedModel.context_length)} context
|
|
</span>
|
|
{selectedModel.max_output_tokens && (
|
|
<span className="flex items-center gap-1">
|
|
<Zap className="h-3 w-3" />
|
|
{formatContextLength(selectedModel.max_output_tokens)} max output
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<DollarSign className="h-3 w-3" />
|
|
In: {formatPrice(selectedModel.input_price)} / Out: {formatPrice(selectedModel.output_price)}
|
|
{selectedModel.currency && selectedModel.input_price && (
|
|
<span className="opacity-60"> {selectedModel.currency}/1K tokens</span>
|
|
)}
|
|
</span>
|
|
{selectedModel.training_cutoff && (
|
|
<span>
|
|
Trained: {new Date(selectedModel.training_cutoff).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent type */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Agent Type</Label>
|
|
<Select value={agentType} onValueChange={(v) => { if (v !== null) setAgentType(v); }}>
|
|
<SelectTrigger
|
|
className="w-full h-8 text-sm"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
|
>
|
|
<SelectValue>
|
|
{agentType === 'react' ? 'ReAct' :
|
|
agentType === 'cot' ? 'CoT' :
|
|
agentType === 'rewoo' ? 'ReWOO' :
|
|
agentType === 'reflexion' ? 'Reflexion' : 'Chat'}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
|
|
<SelectItem value="chat">
|
|
<span className="font-medium">Chat</span>
|
|
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Simple response</span>
|
|
</SelectItem>
|
|
<SelectItem value="react">
|
|
<span className="font-medium">ReAct</span>
|
|
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Multi-step + tools</span>
|
|
</SelectItem>
|
|
<SelectItem value="cot">
|
|
<span className="font-medium">CoT</span>
|
|
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Chain-of-Thought</span>
|
|
</SelectItem>
|
|
<SelectItem value="rewoo">
|
|
<span className="font-medium">ReWOO</span>
|
|
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Plan → Execute → Synthesize</span>
|
|
</SelectItem>
|
|
<SelectItem value="reflexion">
|
|
<span className="font-medium">Reflexion</span>
|
|
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Generate → Critique → Revise</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* System prompt */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>System Prompt</Label>
|
|
<textarea
|
|
className="min-h-[70px] w-full rounded-md px-2.5 py-2 text-sm resize-none focus-visible:outline-none focus-visible:ring-1"
|
|
style={{
|
|
border: '1px solid var(--room-border)',
|
|
background: 'var(--room-bg)',
|
|
color: 'var(--room-text)',
|
|
}}
|
|
placeholder="Optional system prompt..."
|
|
value={systemPrompt}
|
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Advanced */}
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 text-xs transition-colors"
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
onClick={() => setShowAdvanced((v) => !v)}
|
|
>
|
|
{showAdvanced ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
Advanced options
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="space-y-2.5 pl-1">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Temperature</Label>
|
|
<Input
|
|
type="number" step="0.1" min="0" max="2"
|
|
placeholder="0.7"
|
|
value={temperature}
|
|
onChange={(e) => setTemperature(e.target.value)}
|
|
className="h-7 text-sm"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Max Tokens</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="4096"
|
|
value={maxTokens}
|
|
onChange={(e) => setMaxTokens(e.target.value)}
|
|
className="h-7 text-sm"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>History Limit</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="50"
|
|
value={historyLimit}
|
|
onChange={(e) => setHistoryLimit(e.target.value)}
|
|
className="h-7 text-sm"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Use Exact</Label>
|
|
<Switch checked={useExact} onCheckedChange={setUseExact} size="sm" />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Think Mode</Label>
|
|
<Switch checked={think} onCheckedChange={setThink} size="sm" />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Streaming</Label>
|
|
<Switch checked={stream} onCheckedChange={setStream} size="sm" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1 h-8 text-xs"
|
|
onClick={() => setSelectedModel(null)}
|
|
style={{ borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
className="flex-1 h-8 text-xs"
|
|
disabled={isAddingAi}
|
|
onClick={handleAddAi}
|
|
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)', border: 'none' }}
|
|
>
|
|
{isAddingAi ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Plus className="mr-1.5 h-3.5 w-3.5" />}
|
|
Add to Room
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
// ── Main Settings View ──
|
|
return (
|
|
<aside
|
|
className="flex flex-col h-full"
|
|
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)' }}
|
|
>
|
|
<header
|
|
className="flex h-12 items-center border-b px-4 shrink-0"
|
|
style={{ borderColor: 'var(--room-border)' }}
|
|
>
|
|
<p className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>Room Settings</p>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{/* General Section */}
|
|
<section className="space-y-3">
|
|
<h3
|
|
className="text-xs font-semibold uppercase tracking-wide"
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
>
|
|
General
|
|
</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Room name</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Room name"
|
|
className="border-border/70 bg-muted/30"
|
|
style={{
|
|
background: 'var(--room-bg)',
|
|
borderColor: 'var(--room-border)',
|
|
color: 'var(--room-text)',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium" style={{ color: 'var(--room-text)' }}>Public</p>
|
|
<p className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Visible to all project members</p>
|
|
</div>
|
|
<Switch
|
|
checked={isPublic}
|
|
onCheckedChange={setIsPublic}
|
|
aria-label="Public room visibility"
|
|
style={{ '--room-accent': 'var(--room-accent)' } as React.CSSProperties}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={!name.trim() || isPending}
|
|
className="w-full border-none"
|
|
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)' }}
|
|
>
|
|
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
Save Changes
|
|
</Button>
|
|
</section>
|
|
|
|
{/* AI Section */}
|
|
<section className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3
|
|
className="text-xs font-semibold uppercase tracking-wide"
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
>
|
|
AI Models
|
|
</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2"
|
|
onClick={openAddPanel}
|
|
style={{ color: 'var(--room-accent)' }}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{aiConfigsLoading ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
|
</div>
|
|
) : aiConfigs.length === 0 ? (
|
|
<p className="text-xs py-2" style={{ color: 'var(--room-text-muted)' }}>No AI models configured</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{aiConfigs.map((config) => (
|
|
<div
|
|
key={config.model}
|
|
className="flex items-center justify-between rounded-md px-3 py-2"
|
|
style={{ border: `1px solid var(--room-border)`, background: 'var(--room-bg)' }}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Bot className="h-4 w-4 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
|
|
<span className="text-sm truncate" style={{ color: 'var(--room-text)' }}>
|
|
{config.model_name ?? 'Unknown'}
|
|
</span>
|
|
{config.stream && (
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[10px] shrink-0"
|
|
style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}
|
|
>
|
|
streaming
|
|
</span>
|
|
)}
|
|
{config.think && (
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[10px] shrink-0"
|
|
style={{ background: 'var(--accent-subtle)', color: 'var(--room-accent)' }}
|
|
>
|
|
think
|
|
</span>
|
|
)}
|
|
{config.agent_type && ['react', 'cot', 'rewoo', 'reflexion'].includes(config.agent_type) && (
|
|
<span
|
|
className="rounded px-1 py-0.5 text-[10px] shrink-0"
|
|
style={{ background: 'rgba(168,85,247,0.15)', color: '#c084fc' }}
|
|
>
|
|
{config.agent_type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0"
|
|
style={{ color: 'var(--room-text-muted)' }}
|
|
onClick={() => handleDeleteAi(config.model)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
);
|
|
});
|