feat(agent): paginated model catalog with pricing + redesigned Add AI panel
Backend:
- New GET /api/agents/models/catalog endpoint with page/per_page/search
params, excludes deprecated models, returns pricing data via
model→version→pricing join
- ModelWithPricingResponse includes input_price, output_price, currency
- ModelListResponse with pagination metadata (total, page, per_page)
- Batch-fetches default versions + latest pricing to avoid N+1
Frontend:
- RoomSettingsPanel: replace Dialog with inline two-step panel
- Step 1: paginated model browser with search, shows context length,
max output tokens, pricing per 1K tokens, capability/modality badges
- Step 2: selected model info card + AI configuration form
- Removed Dialog import and related unused dependencies
This commit is contained in:
parent
bc1bdd8491
commit
7ce113a765
@ -5,6 +5,8 @@
|
||||
use chrono::Utc;
|
||||
use db::database::AppDatabase;
|
||||
use models::agents::model;
|
||||
use models::agents::model_pricing;
|
||||
use models::agents::model_version;
|
||||
use models::agents::{
|
||||
ModelCapability, ModelModality, ModelStatus,
|
||||
model::{Column as MColumn, Entity as MEntity},
|
||||
@ -88,6 +90,140 @@ pub async fn list_models(
|
||||
Ok(models.into_iter().map(ModelResponse::from).collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct ModelWithPricingResponse {
|
||||
pub id: Uuid,
|
||||
pub provider_id: Uuid,
|
||||
pub name: String,
|
||||
pub modality: String,
|
||||
pub capability: String,
|
||||
pub context_length: i64,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub training_cutoff: Option<chrono::DateTime<Utc>>,
|
||||
pub is_open_source: bool,
|
||||
pub status: String,
|
||||
pub input_price: Option<String>,
|
||||
pub output_price: Option<String>,
|
||||
pub currency: Option<String>,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub updated_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct ModelListResponse {
|
||||
pub data: Vec<ModelWithPricingResponse>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
/// List models with pricing, pagination, search, and deprecation filter.
|
||||
pub async fn list_models_with_pricing(
|
||||
db: &AppDatabase,
|
||||
provider_id: Option<Uuid>,
|
||||
search: Option<&str>,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
) -> Result<ModelListResponse, AgentError> {
|
||||
let mut query = MEntity::find()
|
||||
.filter(MColumn::Status.ne("deprecated"))
|
||||
.order_by_asc(MColumn::Name);
|
||||
|
||||
if let Some(pid) = provider_id {
|
||||
query = query.filter(MColumn::ProviderId.eq(pid));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
if !q.is_empty() {
|
||||
query = query.filter(MColumn::Name.contains(q));
|
||||
}
|
||||
}
|
||||
|
||||
let total = query.clone().count(db).await? as u64;
|
||||
let offset = (page.saturating_sub(1)) * per_page;
|
||||
let models = query
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// Batch-fetch default versions for these models
|
||||
let model_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||||
let versions = if model_ids.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
model_version::Entity::find()
|
||||
.filter(model_version::Column::ModelId.is_in(model_ids))
|
||||
.filter(model_version::Column::IsDefault.eq(true))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let version_ids: Vec<Uuid> = versions.iter().map(|v| v.id).collect();
|
||||
let pricings = if version_ids.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
model_pricing::Entity::find()
|
||||
.filter(model_pricing::Column::ModelVersionId.is_in(version_ids))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Build lookup: model_id → latest pricing (by effective_from DESC)
|
||||
let mut pricing_map: std::collections::HashMap<Uuid, &model_pricing::Model> = std::collections::HashMap::new();
|
||||
let version_to_model: std::collections::HashMap<Uuid, Uuid> = versions
|
||||
.iter()
|
||||
.map(|v| (v.id, v.model_id))
|
||||
.collect();
|
||||
|
||||
for p in &pricings {
|
||||
if let Some(model_id) = version_to_model.get(&p.model_version_id) {
|
||||
match pricing_map.get(model_id) {
|
||||
Some(existing) => {
|
||||
if p.effective_from > existing.effective_from {
|
||||
pricing_map.insert(*model_id, p);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
pricing_map.insert(*model_id, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = models
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let pricing = pricing_map.get(&m.id);
|
||||
ModelWithPricingResponse {
|
||||
id: m.id,
|
||||
provider_id: m.provider_id,
|
||||
name: m.name,
|
||||
modality: m.modality,
|
||||
capability: m.capability,
|
||||
context_length: m.context_length,
|
||||
max_output_tokens: m.max_output_tokens,
|
||||
training_cutoff: m.training_cutoff,
|
||||
is_open_source: m.is_open_source,
|
||||
status: m.status,
|
||||
input_price: pricing.map(|p| p.input_price_per_1k_tokens.clone()),
|
||||
output_price: pricing.map(|p| p.output_price_per_1k_tokens.clone()),
|
||||
currency: pricing.map(|p| p.currency.clone()),
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ModelListResponse {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a single model by ID.
|
||||
pub async fn get_model(db: &AppDatabase, id: Uuid) -> Result<ModelResponse, AgentError> {
|
||||
let model = MEntity::find_by_id(id)
|
||||
|
||||
@ -37,6 +37,7 @@ pub fn init_agent_routes(cfg: &mut web::ServiceConfig) {
|
||||
web::delete().to(provider::provider_delete),
|
||||
)
|
||||
.route("/models", web::get().to(model::model_list))
|
||||
.route("/models/catalog", web::get().to(model::model_catalog))
|
||||
.route("/models/{id}", web::get().to(model::model_get))
|
||||
.route("/models", web::post().to(model::model_create))
|
||||
.route("/models/{id}", web::patch().to(model::model_update))
|
||||
|
||||
@ -10,6 +10,14 @@ pub struct ListQuery {
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::IntoParams)]
|
||||
pub struct CatalogQuery {
|
||||
pub provider_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/models",
|
||||
@ -36,6 +44,36 @@ pub async fn model_list(
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/models/catalog",
|
||||
params(CatalogQuery),
|
||||
responses(
|
||||
(status = 200, body = service::agent::model::ModelListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
tag = "Agent"
|
||||
)]
|
||||
pub async fn model_catalog(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
query: web::Query<CatalogQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let provider_id = if let Some(ref s) = query.provider_id {
|
||||
Some(Uuid::parse_str(s).map_err(|_| {
|
||||
service::error::AppError::BadRequest("Invalid provider UUID".to_string())
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let resp = service
|
||||
.agent_model_list_with_pricing(provider_id, query.search.clone(), page, per_page, &session)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/models/{id}",
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::error::AppError;
|
||||
use session::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use agent::model::model_entry::{CreateModelRequest, ModelResponse, UpdateModelRequest};
|
||||
pub use agent::model::model_entry::{CreateModelRequest, ModelListResponse, ModelResponse, UpdateModelRequest, ModelWithPricingResponse};
|
||||
|
||||
impl AppService {
|
||||
pub async fn agent_model_list(
|
||||
@ -16,6 +16,23 @@ impl AppService {
|
||||
Ok(agent::model::model_entry::list_models(&self.db, provider_id).await?)
|
||||
}
|
||||
|
||||
pub async fn agent_model_list_with_pricing(
|
||||
&self,
|
||||
provider_id: Option<Uuid>,
|
||||
search: Option<String>,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
_ctx: &Session,
|
||||
) -> Result<agent::model::model_entry::ModelListResponse, AppError> {
|
||||
Ok(agent::model::model_entry::list_models_with_pricing(
|
||||
&self.db,
|
||||
provider_id,
|
||||
search.as_deref(),
|
||||
page,
|
||||
per_page,
|
||||
).await?)
|
||||
}
|
||||
|
||||
pub async fn agent_model_get(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
||||
@ -1,19 +1,33 @@
|
||||
import React, { memo, useState, useEffect, useCallback } from 'react';
|
||||
import type { ModelResponse, RoomResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
|
||||
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
|
||||
import React, { memo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { RoomResponse, RoomAiResponse, RoomAiUpsertRequest } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, Plus, Trash2, Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
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 {
|
||||
@ -23,6 +37,61 @@ interface RoomSettingsPanelProps {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
interface CatalogModel {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
name: string;
|
||||
modality: string;
|
||||
capability: string;
|
||||
context_length: number;
|
||||
max_output_tokens?: number;
|
||||
training_cutoff?: string;
|
||||
is_open_source: boolean;
|
||||
status: string;
|
||||
input_price?: string;
|
||||
output_price?: string;
|
||||
currency?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
data: CatalogModel[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
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): 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,
|
||||
@ -32,7 +101,6 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
const [name, setName] = useState(room.room_name ?? '');
|
||||
const [isPublic, setIsPublic] = useState(!!room.public);
|
||||
|
||||
// Sync form when room prop changes (e.g., user switched to a different room)
|
||||
useEffect(() => {
|
||||
setName(room.room_name ?? '');
|
||||
setIsPublic(!!room.public);
|
||||
@ -41,13 +109,19 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
// AI section state
|
||||
const [aiConfigs, setAiConfigs] = useState<RoomAiResponse[]>([]);
|
||||
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
||||
const [showAiAddDialog, setShowAiAddDialog] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showAddPanel, setShowAddPanel] = useState(false);
|
||||
|
||||
// Add AI form state
|
||||
const [availableModels, setAvailableModels] = useState<ModelResponse[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
// 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('');
|
||||
@ -56,6 +130,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
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 () => {
|
||||
@ -83,27 +158,57 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
}
|
||||
}, [room.id]);
|
||||
|
||||
// Load available models
|
||||
const loadModels = useCallback(async () => {
|
||||
setModelsLoading(true);
|
||||
useEffect(() => {
|
||||
loadAiConfigs();
|
||||
}, [loadAiConfigs]);
|
||||
|
||||
// Load catalog models
|
||||
const loadCatalog = useCallback(async (search: string, page: number) => {
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const resp = await modelList({});
|
||||
const inner = resp.data as { data?: ModelResponse[] } | undefined;
|
||||
setAvailableModels(Array.isArray(inner?.data) ? inner.data : []);
|
||||
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 {
|
||||
setModelsLoading(false);
|
||||
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(() => {
|
||||
loadAiConfigs();
|
||||
loadModels();
|
||||
}, [loadAiConfigs, loadModels]);
|
||||
if (showAddPanel) {
|
||||
loadCatalog(searchQuery, catalogPage);
|
||||
}
|
||||
}, [catalogPage, showAddPanel]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const openAddDialog = () => {
|
||||
setSelectedModelId('');
|
||||
const openAddPanel = () => {
|
||||
setSelectedModel(null);
|
||||
setSearchQuery('');
|
||||
setCatalogPage(1);
|
||||
setTemperature('');
|
||||
setMaxTokens('');
|
||||
setHistoryLimit('');
|
||||
@ -113,19 +218,15 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
setStream(true);
|
||||
setAgentType('chat');
|
||||
setShowAdvanced(false);
|
||||
setShowAiAddDialog(true);
|
||||
loadModels();
|
||||
setShowAddPanel(true);
|
||||
};
|
||||
|
||||
const handleAddAi = async () => {
|
||||
if (!selectedModelId) {
|
||||
toast.error('Please select a model');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) return;
|
||||
setIsAddingAi(true);
|
||||
try {
|
||||
const body: RoomAiUpsertRequest = {
|
||||
model: selectedModelId,
|
||||
model: selectedModel.id,
|
||||
temperature: temperature ? parseFloat(temperature) : undefined,
|
||||
max_tokens: maxTokens ? parseInt(maxTokens) : undefined,
|
||||
history_limit: historyLimit ? parseInt(historyLimit) : undefined,
|
||||
@ -135,12 +236,10 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
stream,
|
||||
agent_type: agentType || undefined,
|
||||
};
|
||||
await aiUpsert({
|
||||
path: { room_id: room.id },
|
||||
body,
|
||||
});
|
||||
toast.success('AI model added');
|
||||
setShowAiAddDialog(false);
|
||||
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');
|
||||
@ -159,6 +258,357 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
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 (multi-step reasoning)' : 'Chat (simple)'}
|
||||
</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>
|
||||
</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"
|
||||
@ -215,9 +665,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
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}
|
||||
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save Changes
|
||||
</Button>
|
||||
</section>
|
||||
@ -235,7 +683,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
onClick={openAddDialog}
|
||||
onClick={openAddPanel}
|
||||
style={{ color: 'var(--room-accent)' }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@ -260,7 +708,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
<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 ?? availableModels.find((m) => m.id === config.model)?.name ?? 'Unknown'}
|
||||
{config.model_name ?? 'Unknown'}
|
||||
</span>
|
||||
{config.stream && (
|
||||
<span
|
||||
@ -302,194 +750,6 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Add AI Dialog */}
|
||||
<Dialog open={showAiAddDialog} onOpenChange={setShowAiAddDialog}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[440px]"
|
||||
style={{
|
||||
background: 'var(--room-bg)',
|
||||
border: '1px solid var(--room-border)',
|
||||
color: 'var(--room-text)',
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2" style={{ color: 'var(--room-text)' }}>
|
||||
<Bot className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
||||
Add AI Model
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Model selection */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Model</Label>
|
||||
{modelsLoading ? (
|
||||
<div
|
||||
className="flex items-center gap-2 h-8 px-3 rounded-md text-sm"
|
||||
style={{ border: '1px solid var(--room-border)', color: 'var(--room-text-muted)' }}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : (
|
||||
<Select value={selectedModelId} onValueChange={(v) => { if (v !== null) setSelectedModelId(v); }}>
|
||||
<SelectTrigger className="w-full" style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}>
|
||||
<SelectValue placeholder="Select a model...">
|
||||
{selectedModelId
|
||||
? availableModels.find((m) => m.id === selectedModelId)?.name ?? selectedModelId
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>
|
||||
{model.capability} · {model.modality}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>System Prompt</Label>
|
||||
<textarea
|
||||
className="min-h-[80px] w-full rounded-md px-3 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)',
|
||||
'--ring-color': 'var(--room-accent)',
|
||||
} as React.CSSProperties}
|
||||
placeholder="Optional system prompt for this AI..."
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent type */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Agent Type</Label>
|
||||
<Select value={agentType} onValueChange={(v) => { if (v !== null) setAgentType(v); }}>
|
||||
<SelectTrigger className="w-full" style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}>
|
||||
<SelectValue>
|
||||
{agentType === 'react' ? 'ReAct (multi-step reasoning)' : 'Chat (simple)'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
|
||||
<SelectItem value="chat">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Simple single-turn response</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="react">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">ReAct</span>
|
||||
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Multi-step reasoning with tool use</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings toggle */}
|
||||
<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-3 pl-1">
|
||||
{/* Temperature */}
|
||||
<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="e.g. 0.7"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max tokens */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Max Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 4096"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* History limit */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>History Limit (messages)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 50"
|
||||
value={historyLimit}
|
||||
onChange={(e) => setHistoryLimit(e.target.value)}
|
||||
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAiAddDialog(false)}
|
||||
style={{ borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddAi}
|
||||
disabled={!selectedModelId || isAddingAi}
|
||||
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)', border: 'none' }}
|
||||
>
|
||||
{isAddingAi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Add Model
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user