diff --git a/libs/agent/model/model_entry.rs b/libs/agent/model/model_entry.rs index 17d524f..27176de 100644 --- a/libs/agent/model/model_entry.rs +++ b/libs/agent/model/model_entry.rs @@ -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, + pub training_cutoff: Option>, + pub is_open_source: bool, + pub status: String, + pub input_price: Option, + pub output_price: Option, + pub currency: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct ModelListResponse { + pub data: Vec, + 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, + search: Option<&str>, + page: u64, + per_page: u64, +) -> Result { + 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 = 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 = 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 = std::collections::HashMap::new(); + let version_to_model: std::collections::HashMap = 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 { let model = MEntity::find_by_id(id) diff --git a/libs/api/agent/mod.rs b/libs/api/agent/mod.rs index ad7d46e..e406148 100644 --- a/libs/api/agent/mod.rs +++ b/libs/api/agent/mod.rs @@ -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)) diff --git a/libs/api/agent/model.rs b/libs/api/agent/model.rs index a24c3c0..f099ad9 100644 --- a/libs/api/agent/model.rs +++ b/libs/api/agent/model.rs @@ -10,6 +10,14 @@ pub struct ListQuery { pub provider_id: Option, } +#[derive(serde::Deserialize, utoipa::IntoParams)] +pub struct CatalogQuery { + pub provider_id: Option, + pub search: Option, + pub page: Option, + pub per_page: Option, +} + #[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, + session: Session, + query: web::Query, +) -> Result { + 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}", diff --git a/libs/service/agent/model.rs b/libs/service/agent/model.rs index 7bde267..8fdde71 100644 --- a/libs/service/agent/model.rs +++ b/libs/service/agent/model.rs @@ -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, + search: Option, + page: u64, + per_page: u64, + _ctx: &Session, + ) -> Result { + 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, diff --git a/src/components/room/RoomSettingsPanel.tsx b/src/components/room/RoomSettingsPanel.tsx index 22d3081..9b0d640 100644 --- a/src/components/room/RoomSettingsPanel.tsx +++ b/src/components/room/RoomSettingsPanel.tsx @@ -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 = { + chat: '#3b82f6', + code: '#8b5cf6', + completion: '#06b6d4', + embedding: '#f59e0b', + vision: '#ec4899', + reasoning: '#10b981', +}; + +const MODALITY_ICONS: Record = { + 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([]); 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([]); - const [modelsLoading, setModelsLoading] = useState(false); - const [selectedModelId, setSelectedModelId] = useState(''); + // Catalog state + const [catalogModels, setCatalogModels] = useState([]); + 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>(undefined); + + // Selected model + config form state + const [selectedModel, setSelectedModel] = useState(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 ( +