diff --git a/backend/app/api/endpoints/adapter/models.py b/backend/app/api/endpoints/adapter/models.py index d783dcb0..e42fa419 100644 --- a/backend/app/api/endpoints/adapter/models.py +++ b/backend/app/api/endpoints/adapter/models.py @@ -2,9 +2,10 @@ # # SPDX-License-Identifier: Apache-2.0 -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, Query, status, Body from sqlalchemy.orm import Session -from typing import List +from typing import List, Dict, Any +from pydantic import BaseModel from app.api.dependencies import get_db from app.core import security @@ -22,6 +23,117 @@ router = APIRouter() +class TestConnectionRequest(BaseModel): + provider: str + config: Dict[str, Any] + + +class TestConnectionResponse(BaseModel): + success: bool + message: str + + +class BotReference(BaseModel): + bot_id: int + bot_name: str + + +class CheckReferencesResponse(BaseModel): + is_referenced: bool + referenced_by: List[BotReference] + + +@router.post("/test", response_model=TestConnectionResponse) +def test_connection( + request: TestConnectionRequest = Body(...), + current_user: User = Depends(security.get_current_user), +): + """ + Test model connection with provided configuration + """ + try: + # For now, we'll do a simple validation + # In a real implementation, you would: + # 1. Extract provider type and credentials from request.config + # 2. Initialize the appropriate SDK client + # 3. Make a simple API call to verify credentials + + provider = request.provider + config = request.config.get("env", {}) + + # Basic validation + if not config.get("model_id"): + return TestConnectionResponse( + success=False, + message="Model ID is required" + ) + + if not config.get("api_key"): + return TestConnectionResponse( + success=False, + message="API Key is required" + ) + + # TODO: Implement actual connection testing based on provider + # For now, return success after basic validation + return TestConnectionResponse( + success=True, + message=f"Connection test successful for {provider}" + ) + + except Exception as e: + return TestConnectionResponse( + success=False, + message=f"Connection test failed: {str(e)}" + ) + + +@router.get("/{model_id}/check-references", response_model=CheckReferencesResponse) +def check_references( + model_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """ + Check if the model is referenced by any bots + """ + # Get the model name first + try: + model_dict = public_model_service.get_by_id(db=db, model_id=model_id, current_user=current_user) + model_name = model_dict.get("name") + except Exception: + return CheckReferencesResponse( + is_referenced=False, + referenced_by=[] + ) + + # Query bots table to check for references + referenced_by = [] + try: + from app.models.bot import Bot + + # Check bots that use this model in their agent_config + bots = db.query(Bot).filter(Bot.is_active == True).all() + + for bot in bots: + # Check if bot's agent_config references this model + if hasattr(bot, 'agent_config') and isinstance(bot.agent_config, dict): + private_model = bot.agent_config.get('private_model') + if private_model == model_name: + referenced_by.append(BotReference( + bot_id=bot.id, + bot_name=bot.name + )) + except Exception: + # If bot model doesn't exist or query fails, return empty list + pass + + return CheckReferencesResponse( + is_referenced=len(referenced_by) > 0, + referenced_by=referenced_by + ) + + @router.get("", response_model=ModelListResponse) def list_models( page: int = Query(1, ge=1, description="Page number"), diff --git a/frontend/src/apis/models.ts b/frontend/src/apis/models.ts index 56c1e972..e9bfabde 100644 --- a/frontend/src/apis/models.ts +++ b/frontend/src/apis/models.ts @@ -9,13 +9,86 @@ export interface Model { name: string } +export interface ModelDetail { + id: number + name: string + config: { + env: { + model: string // Provider type (claude, openai, etc.) + model_id: string + api_key: string + base_url?: string + [key: string]: any // Other provider-specific fields + } + } + is_active: boolean + created_at: string + updated_at: string +} + export interface ModelNamesResponse { data: Model[] } +export interface ModelListResponse { + total: number + items: ModelDetail[] +} + +export interface ModelCreateRequest { + name: string + config: { + env: Record + } + is_active?: boolean +} + +export interface TestConnectionRequest { + provider: string + config: { + env: Record + } +} + +export interface CheckReferencesResponse { + is_referenced: boolean + referenced_by: Array<{ + bot_id: number + bot_name: string + }> +} + // Model Services export const modelApis = { async getModelNames(agentName: string): Promise { return apiClient.get(`/models/names?agent_name=${encodeURIComponent(agentName)}`) + }, + + async getModels(page: number = 1, limit: number = 50): Promise { + return apiClient.get(`/models?page=${page}&limit=${limit}`) + }, + + async getModelById(id: number): Promise { + return apiClient.get(`/models/${id}`) + }, + + async createModel(data: ModelCreateRequest): Promise { + return apiClient.post('/models', data) + }, + + async updateModel(id: number, data: ModelCreateRequest): Promise { + return apiClient.put(`/models/${id}`, data) + }, + + async deleteModel(id: number): Promise { + return apiClient.delete(`/models/${id}`) + }, + + async testConnection(data: TestConnectionRequest): Promise<{ success: boolean; message: string }> { + return apiClient.post('/models/test', data) + }, + + async checkReferences(id: number): Promise { + return apiClient.get(`/models/${id}/check-references`) } } \ No newline at end of file diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 6f8f2860..33ba67d2 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -9,12 +9,13 @@ import { useRouter, useSearchParams } from 'next/navigation'; import TopNavigation from '@/features/layout/TopNavigation'; import UserMenu from '@/features/layout/UserMenu'; import { Tab } from '@headlessui/react'; -import { PuzzlePieceIcon, UsersIcon, BellIcon } from '@heroicons/react/24/outline'; +import { PuzzlePieceIcon, UsersIcon, BellIcon, CubeIcon } from '@heroicons/react/24/outline'; import { RiRobot2Line } from 'react-icons/ri'; import GitHubIntegration from '@/features/settings/components/GitHubIntegration'; import BotList from '@/features/settings/components/BotList'; import TeamList from '@/features/settings/components/TeamList'; import NotificationSettings from '@/features/settings/components/NotificationSettings'; +import ModelList from '@/features/settings/components/ModelList'; import { UserProvider } from '@/features/common/UserContext'; import { useTranslation } from '@/hooks/useTranslation'; import { GithubStarButton } from '@/features/layout/GithubStarButton'; @@ -28,9 +29,10 @@ function DashboardContent() { const tabIndexToName = useMemo( (): Record => ({ 0: 'integrations', - 1: 'bots', - 2: 'team', - 3: 'notifications', + 1: 'models', + 2: 'bots', + 3: 'team', + 4: 'notifications', }), [] ); @@ -39,9 +41,10 @@ function DashboardContent() { const tabNameToIndex = useMemo( (): Record => ({ integrations: 0, - bots: 1, - team: 2, - notifications: 3, + models: 1, + bots: 2, + team: 3, + notifications: 4, }), [] ); @@ -116,6 +119,19 @@ function DashboardContent() { {t('settings.integrations')} + + `w-full flex items-center space-x-3 px-3 py-2 text-sm rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('settings.models')} + + `w-full flex items-center space-x-3 px-3 py-2 text-sm rounded-md transition-colors duration-200 focus:outline-none ${ @@ -161,6 +177,9 @@ function DashboardContent() { + + + @@ -191,6 +210,19 @@ function DashboardContent() { {t('settings.integrations')} + + `flex-1 flex items-center justify-center space-x-1 px-2 py-2 text-xs rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('settings.models')} + + `flex-1 flex items-center justify-center space-x-1 px-2 py-2 text-xs rounded-md transition-colors duration-200 focus:outline-none ${ @@ -237,6 +269,9 @@ function DashboardContent() { + + + diff --git a/frontend/src/features/settings/components/ModelEdit.tsx b/frontend/src/features/settings/components/ModelEdit.tsx new file mode 100644 index 00000000..acb3b1a9 --- /dev/null +++ b/frontend/src/features/settings/components/ModelEdit.tsx @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ModelDetail, modelApis, ModelCreateRequest } from '@/apis/models'; +import { useTranslation } from 'react-i18next'; +import ClaudeModelForm from './modelForms/ClaudeModelForm'; +import OpenAIModelForm from './modelForms/OpenAIModelForm'; +import OpenRouterModelForm from './modelForms/OpenRouterModelForm'; +import DeepSeekModelForm from './modelForms/DeepSeekModelForm'; +import GLMModelForm from './modelForms/GLMModelForm'; +import QwenModelForm from './modelForms/QwenModelForm'; + +interface ModelEditProps { + models: ModelDetail[]; + setModels: React.Dispatch>; + editingModelId: number | null; + cloningModel: ModelDetail | null; + onClose: () => void; + toast: ReturnType['toast']; +} + +type ProviderType = 'claude' | 'openai' | 'openrouter' | 'deepseek' | 'glm' | 'qwen'; + +const ModelEdit: React.FC = ({ + models, + setModels, + editingModelId, + cloningModel, + onClose, + toast, +}) => { + const { t } = useTranslation('common'); + + const [modelSaving, setModelSaving] = useState(false); + const [testingConnection, setTestingConnection] = useState(false); + const [errors, setErrors] = useState>({}); + + // Current editing object + const editingModel = + editingModelId && editingModelId > 0 + ? models.find(m => m.id === editingModelId) || null + : null; + + const baseModel = editingModel || cloningModel || null; + + const [modelName, setModelName] = useState(baseModel?.name || ''); + const [provider, setProvider] = useState( + (baseModel?.config?.env?.model as ProviderType) || 'claude' + ); + const [formData, setFormData] = useState>( + baseModel?.config?.env || { + model_id: '', + api_key: '', + base_url: '', + } + ); + + // Reset form when switching editing object + useEffect(() => { + setModelName(baseModel?.name || ''); + setProvider((baseModel?.config?.env?.model as ProviderType) || 'claude'); + setFormData( + baseModel?.config?.env || { + model_id: '', + api_key: '', + base_url: '', + } + ); + setErrors({}); + }, [editingModelId, baseModel]); + + const handleBack = useCallback(() => { + onClose(); + }, [onClose]); + + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + handleBack(); + }; + + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, [handleBack]); + + const handleFormFieldChange = useCallback((field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + setErrors(prev => ({ ...prev, [field]: false })); + }, []); + + const handleProviderChange = useCallback((value: string) => { + setProvider(value as ProviderType); + setFormData({ + model_id: '', + api_key: '', + base_url: '', + }); + setErrors({}); + }, []); + + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + if (!modelName.trim()) { + newErrors.name = true; + } + + if (!formData.model_id?.trim()) { + newErrors.model_id = true; + } + + if (!formData.api_key?.trim()) { + newErrors.api_key = true; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [modelName, formData]); + + const handleTestConnection = async () => { + if (!validateForm()) { + toast({ + variant: 'destructive', + title: t('settings.model.errors.required'), + }); + return; + } + + setTestingConnection(true); + try { + const result = await modelApis.testConnection({ + provider, + config: { + env: { + model: provider, + ...formData, + }, + }, + }); + + if (result.success) { + toast({ + title: t('settings.model.test_success'), + }); + } else { + toast({ + variant: 'destructive', + title: t('settings.model.test_failed') + ': ' + result.message, + }); + } + } catch (error) { + toast({ + variant: 'destructive', + title: t('settings.model.test_failed') + ': ' + (error as Error).message, + }); + } finally { + setTestingConnection(false); + } + }; + + const handleSave = async () => { + if (!validateForm()) { + toast({ + variant: 'destructive', + title: t('settings.model.errors.required'), + }); + return; + } + + setModelSaving(true); + try { + const modelReq: ModelCreateRequest = { + name: modelName.trim(), + config: { + env: { + model: provider, + ...formData, + }, + }, + is_active: true, + }; + + if (editingModelId && editingModelId > 0) { + // Edit existing model + const updated = await modelApis.updateModel(editingModelId, modelReq); + setModels(prev => prev.map(m => (m.id === editingModelId ? updated : m))); + } else { + // Create new model + const created = await modelApis.createModel(modelReq); + setModels(prev => [created, ...prev]); + } + onClose(); + } catch (error) { + toast({ + variant: 'destructive', + title: (error as Error)?.message || t('settings.model.errors.create_failed'), + }); + } finally { + setModelSaving(false); + } + }; + + const renderProviderForm = () => { + const props = { + formData, + onChange: handleFormFieldChange, + errors, + }; + + switch (provider) { + case 'claude': + return ; + case 'openai': + return ; + case 'openrouter': + return ; + case 'deepseek': + return ; + case 'glm': + return ; + case 'qwen': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Top navigation bar */} +
+ +
+ + +
+
+ + {/* Main content area - responsive layout */} +
+
+ {/* Model Name */} +
+
+ +
+ { + setModelName(e.target.value); + setErrors(prev => ({ ...prev, name: false })); + }} + placeholder={t('settings.model.name_placeholder')} + className={`w-full px-4 py-1 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.name + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ + {/* Provider */} +
+
+ +
+ +
+ + {/* Provider-specific form */} + {renderProviderForm()} +
+ + {/* Right help area */} +
+
+
+ +
+
+ +
+

+ {t('settings.model.help_description') || + 'Fill in the configuration fields for your selected provider. Test the connection before saving to ensure your credentials are valid.'} +

+
+
+
+
+ ); +}; + +export default ModelEdit; diff --git a/frontend/src/features/settings/components/ModelList.tsx b/frontend/src/features/settings/components/ModelList.tsx new file mode 100644 index 00000000..765d6816 --- /dev/null +++ b/frontend/src/features/settings/components/ModelList.tsx @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; +import '@/features/common/scrollbar.css'; + +import { useCallback, useEffect, useState } from 'react'; +import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from '@heroicons/react/24/outline'; +import { CubeIcon } from '@heroicons/react/24/outline'; +import LoadingState from '@/features/common/LoadingState'; +import { ModelDetail, modelApis } from '@/apis/models'; +import { agentApis } from '@/apis/agents'; +import ModelEdit from './ModelEdit'; +import UnifiedAddButton from '@/components/common/UnifiedAddButton'; +import { useTranslation } from '@/hooks/useTranslation'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Tag } from '@/components/ui/tag'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useToast } from '@/hooks/use-toast'; + +export default function ModelList() { + const { t } = useTranslation('common'); + const { toast } = useToast(); + const [models, setModels] = useState([]); + const [filteredModels, setFilteredModels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [editingModelId, setEditingModelId] = useState(null); + const [cloningModel, setCloningModel] = useState(null); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); + const [modelToDelete, setModelToDelete] = useState(null); + const [selectedAgent, setSelectedAgent] = useState(''); + const [agentNames, setAgentNames] = useState([]); + const [loadingAgents, setLoadingAgents] = useState(false); + const isEditing = editingModelId !== null; + + // Load agents + useEffect(() => { + async function loadAgents() { + setLoadingAgents(true); + try { + const response = await agentApis.getAgents(); + setAgentNames(response.items.map(agent => agent.name)); + } catch (error) { + console.error('Failed to fetch agents:', error); + } finally { + setLoadingAgents(false); + } + } + loadAgents(); + }, []); + + // Load models + useEffect(() => { + async function loadModels() { + setIsLoading(true); + try { + const response = await modelApis.getModels(1, 100); + setModels(response.items); + setFilteredModels(response.items); + } catch (error) { + toast({ + variant: 'destructive', + title: t('settings.model.errors.fetch_failed'), + }); + } finally { + setIsLoading(false); + } + } + loadModels(); + }, [toast, t]); + + // Filter models by agent + useEffect(() => { + if (!selectedAgent) { + setFilteredModels(models); + return; + } + + async function filterByAgent() { + try { + const response = await modelApis.getModelNames(selectedAgent); + const modelNames = response.data.map(m => m.name); + + if (modelNames.length === 0) { + // If empty array, show all models + setFilteredModels(models); + } else { + // Filter models by name + const filtered = models.filter(model => modelNames.includes(model.name)); + setFilteredModels(filtered); + } + } catch (error) { + console.error('Failed to filter models:', error); + setFilteredModels(models); + } + } + + filterByAgent(); + }, [selectedAgent, models]); + + const handleCreateModel = () => { + setCloningModel(null); + setEditingModelId(0); + }; + + const handleEditModel = (model: ModelDetail) => { + setCloningModel(null); + setEditingModelId(model.id); + }; + + const handleCloneModel = (model: ModelDetail) => { + setCloningModel(model); + setEditingModelId(0); + }; + + const handleCloseEditor = () => { + setEditingModelId(null); + setCloningModel(null); + }; + + const handleDeleteModel = async (modelId: number) => { + try { + // Check references first + const checkResult = await modelApis.checkReferences(modelId); + + if (checkResult.is_referenced) { + const botNames = checkResult.referenced_by.map(ref => ref.bot_name).join(', '); + toast({ + variant: 'destructive', + title: t('settings.model.delete_referenced_error', { bots: botNames }), + }); + return; + } + + setModelToDelete(modelId); + setDeleteConfirmVisible(true); + } catch (error) { + console.error('Failed to check references:', error); + // If check fails, still allow showing the delete dialog + setModelToDelete(modelId); + setDeleteConfirmVisible(true); + } + }; + + const handleConfirmDelete = async () => { + if (!modelToDelete) return; + + try { + await modelApis.deleteModel(modelToDelete); + setModels(prev => prev.filter(m => m.id !== modelToDelete)); + setDeleteConfirmVisible(false); + setModelToDelete(null); + toast({ + title: t('settings.model.delete_success') || 'Model deleted successfully', + }); + } catch (e) { + const errorMessage = e instanceof Error && e.message ? e.message : t('settings.model.errors.delete_failed'); + toast({ + variant: 'destructive', + title: errorMessage, + }); + } + }; + + const handleCancelDelete = () => { + setDeleteConfirmVisible(false); + setModelToDelete(null); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + }; + + return ( + <> +
+
+

{t('settings.model.title')}

+

+ {t('settings.model.description') || 'Manage your AI model configurations'} +

+
+
+ {isLoading ? ( + + ) : ( + <> + {/* Edit/New mode */} + {isEditing ? ( + + ) : ( + <> + {/* Filter and Add button */} +
+
+ + {t('settings.model.filter_by_agent')}: + + +
+ + {t('settings.model.create')} + +
+ +
+ {filteredModels.length > 0 ? ( + filteredModels.map(model => ( + +
+
+ +
+
+

+ {model.name} +

+
+
+ + {model.is_active ? t('settings.model.active') || 'Active' : t('settings.model.inactive') || 'Inactive'} + +
+
+
+ + {t(`settings.model.providers.${model.config?.env?.model}`) || model.config?.env?.model} + + + {model.config?.env?.model_id} + + + {t('settings.model.updated_at')}: {formatDate(model.updated_at)} + +
+
+
+
+ + + +
+
+
+ )) + ) : ( +
+

{t('settings.model.no_models') || 'No models available'}

+
+ )} +
+ + )} + + )} +
+
+ + {/* Delete confirmation dialog */} + + + + {t('settings.model.delete_confirm_title')} + {t('settings.model.delete_confirm_message')} + + + + + + + + + ); +} diff --git a/frontend/src/features/settings/components/modelForms/ClaudeModelForm.tsx b/frontend/src/features/settings/components/modelForms/ClaudeModelForm.tsx new file mode 100644 index 00000000..42f78978 --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/ClaudeModelForm.tsx @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface ClaudeModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const ClaudeModelForm: React.FC = ({ formData, onChange, errors = {} }) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder={t('settings.model.base_url_placeholder')} + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+ +
+ + onChange('default_haiku_model', e.target.value)} + placeholder="claude-3-5-haiku-20241022" + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default ClaudeModelForm; diff --git a/frontend/src/features/settings/components/modelForms/DeepSeekModelForm.tsx b/frontend/src/features/settings/components/modelForms/DeepSeekModelForm.tsx new file mode 100644 index 00000000..0d026be0 --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/DeepSeekModelForm.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface DeepSeekModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const DeepSeekModelForm: React.FC = ({ formData, onChange, errors = {} }) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder="https://api.deepseek.com" + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default DeepSeekModelForm; diff --git a/frontend/src/features/settings/components/modelForms/GLMModelForm.tsx b/frontend/src/features/settings/components/modelForms/GLMModelForm.tsx new file mode 100644 index 00000000..ee6c6929 --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/GLMModelForm.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface GLMModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const GLMModelForm: React.FC = ({ formData, onChange, errors = {} }) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder="https://open.bigmodel.cn/api/paas/v4" + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default GLMModelForm; diff --git a/frontend/src/features/settings/components/modelForms/OpenAIModelForm.tsx b/frontend/src/features/settings/components/modelForms/OpenAIModelForm.tsx new file mode 100644 index 00000000..2b6cdf99 --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/OpenAIModelForm.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface OpenAIModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const OpenAIModelForm: React.FC = ({ formData, onChange, errors = {} }) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder={t('settings.model.base_url_placeholder')} + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default OpenAIModelForm; diff --git a/frontend/src/features/settings/components/modelForms/OpenRouterModelForm.tsx b/frontend/src/features/settings/components/modelForms/OpenRouterModelForm.tsx new file mode 100644 index 00000000..d8d08d2d --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/OpenRouterModelForm.tsx @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface OpenRouterModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const OpenRouterModelForm: React.FC = ({ + formData, + onChange, + errors = {}, +}) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder="https://openrouter.ai/api/v1" + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default OpenRouterModelForm; diff --git a/frontend/src/features/settings/components/modelForms/QwenModelForm.tsx b/frontend/src/features/settings/components/modelForms/QwenModelForm.tsx new file mode 100644 index 00000000..67103348 --- /dev/null +++ b/frontend/src/features/settings/components/modelForms/QwenModelForm.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface QwenModelFormProps { + formData: Record; + onChange: (field: string, value: string) => void; + errors?: Record; +} + +const QwenModelForm: React.FC = ({ formData, onChange, errors = {} }) => { + const { t } = useTranslation('common'); + + return ( +
+
+ + onChange('model_id', e.target.value)} + placeholder={t('settings.model.model_id_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.model_id + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('api_key', e.target.value)} + placeholder={t('settings.model.api_key_placeholder')} + className={`w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 text-base ${ + errors.api_key + ? 'border border-red-400 focus:ring-red-300' + : 'border border-transparent focus:ring-primary/40' + }`} + /> +
+ +
+ + onChange('base_url', e.target.value)} + placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" + className="w-full px-4 py-2 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 border border-transparent text-base" + /> +
+
+ ); +}; + +export default QwenModelForm; diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 9579c925..685ca8dd 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -252,8 +252,61 @@ "integrations": "Integrations", "bot": "Bot", "team": "Team", + "models": "Models", "sections": { "general": "General" + }, + "model": { + "title": "Model Management", + "description": "Manage your AI model configurations", + "create": "Create Model", + "edit": "Edit Model", + "delete": "Delete Model", + "copy": "Copy Model", + "name": "Model Name", + "name_placeholder": "Enter model name", + "provider": "Provider", + "provider_placeholder": "Select model provider", + "model_id": "Model ID", + "model_id_placeholder": "Enter model ID", + "api_key": "API Key", + "api_key_placeholder": "Enter API Key", + "base_url": "Base URL", + "base_url_placeholder": "Enter Base URL (optional)", + "filter_by_agent": "Filter by Agent", + "all_agents": "All Agents", + "test_connection": "Test Connection", + "testing": "Testing...", + "test_success": "Connection test successful", + "test_failed": "Connection test failed", + "created_at": "Created At", + "updated_at": "Updated At", + "active": "Active", + "inactive": "Inactive", + "delete_confirm_title": "Confirm Delete", + "delete_confirm_message": "Are you sure you want to delete this model? This action cannot be undone.", + "delete_referenced_error": "This model is being used by the following Bots and cannot be deleted: {{bots}}", + "delete_success": "Model deleted successfully", + "no_models": "No models available", + "loading": "Loading models...", + "help_title": "Configuration Help", + "help_description": "Fill in the configuration fields for your selected provider. Test the connection before saving to ensure your credentials are valid.", + "providers": { + "claude": "Claude", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "deepseek": "DeepSeek", + "glm": "GLM", + "qwen": "Qwen" + }, + "errors": { + "required": "Please fill in all required fields", + "name_exists": "Model name already exists", + "fetch_failed": "Failed to fetch models", + "create_failed": "Failed to create model", + "update_failed": "Failed to update model", + "delete_failed": "Failed to delete model" + } } }, "notifications": { diff --git a/frontend/src/i18n/locales/zh-CN/common.json b/frontend/src/i18n/locales/zh-CN/common.json index 25053b48..dea97dc8 100644 --- a/frontend/src/i18n/locales/zh-CN/common.json +++ b/frontend/src/i18n/locales/zh-CN/common.json @@ -255,8 +255,61 @@ "integrations": "集成", "bot": "机器人", "team": "团队", + "models": "模型", "sections": { "general": "通用" + }, + "model": { + "title": "模型管理", + "description": "管理您的 AI 模型配置", + "create": "创建模型", + "edit": "编辑模型", + "delete": "删除模型", + "copy": "复制模型", + "name": "模型名称", + "name_placeholder": "请输入模型名称", + "provider": "提供商", + "provider_placeholder": "选择模型提供商", + "model_id": "Model ID", + "model_id_placeholder": "请输入模型 ID", + "api_key": "API Key", + "api_key_placeholder": "请输入 API Key", + "base_url": "Base URL", + "base_url_placeholder": "请输入 Base URL(可选)", + "filter_by_agent": "按 Agent 筛选", + "all_agents": "所有 Agent", + "test_connection": "测试连接", + "testing": "测试中...", + "test_success": "连接测试成功", + "test_failed": "连接测试失败", + "created_at": "创建时间", + "updated_at": "更新时间", + "active": "活跃", + "inactive": "非活跃", + "delete_confirm_title": "确认删除", + "delete_confirm_message": "确定要删除该模型吗?此操作无法撤销。", + "delete_referenced_error": "该模型正在被以下 Bot 使用,无法删除:{{bots}}", + "delete_success": "模型删除成功", + "no_models": "没有可用的模型", + "loading": "加载模型中...", + "help_title": "配置帮助", + "help_description": "填写所选提供商的配置字段。保存前测试连接以确保凭据有效。", + "providers": { + "claude": "Claude", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "deepseek": "DeepSeek", + "glm": "GLM", + "qwen": "千问" + }, + "errors": { + "required": "请填写所有必填项", + "name_exists": "模型名称已存在", + "fetch_failed": "获取模型列表失败", + "create_failed": "创建模型失败", + "update_failed": "更新模型失败", + "delete_failed": "删除模型失败" + } } }, "notifications": {