chore: layout optimize

This commit is contained in:
qixinbo
2026-03-29 14:44:32 +08:00
parent 74fb360df6
commit 22e1891a57
10 changed files with 1316 additions and 925 deletions
+96
View File
@@ -0,0 +1,96 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends
from openai import OpenAI
from app.schemas.embedding_model import (
EmbeddingModelConfig,
EmbeddingModelConfigCreate,
EmbeddingModelConfigUpdate,
EmbeddingModelConnectionTestRequest
)
from app.services.embedding_model_store import embedding_model_store
from app.services.openai_compat import normalize_openai_base_url
from app.api.llm import get_admin_user, get_current_user, CurrentUser
router = APIRouter()
def _mask_api_key(value: Optional[str]) -> Optional[str]:
if not value:
return None
if len(value) <= 8:
return "*" * len(value)
return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
@router.get("/embedding-models", response_model=List[EmbeddingModelConfig])
def list_embedding_models(current_user: CurrentUser = Depends(get_current_user)):
models = embedding_model_store.list_models()
for m in models:
if not current_user.is_admin:
m["api_key"] = None
return models
@router.post("/embedding-models", response_model=EmbeddingModelConfig)
def create_embedding_model(payload: EmbeddingModelConfigCreate, _: CurrentUser = Depends(get_admin_user)):
return embedding_model_store.create_model(payload.model_dump())
@router.get("/embedding-models/{model_id}", response_model=EmbeddingModelConfig)
def get_embedding_model(model_id: str, current_user: CurrentUser = Depends(get_current_user)):
model = embedding_model_store.get_model(model_id)
if not model:
raise HTTPException(status_code=404, detail="Embedding model not found")
if not current_user.is_admin:
model["api_key"] = None
return model
@router.put("/embedding-models/{model_id}", response_model=EmbeddingModelConfig)
def update_embedding_model(model_id: str, payload: EmbeddingModelConfigUpdate, _: CurrentUser = Depends(get_admin_user)):
model = embedding_model_store.update_model(model_id, payload.model_dump(exclude_unset=True))
if not model:
raise HTTPException(status_code=404, detail="Embedding model not found")
return model
@router.delete("/embedding-models/{model_id}")
def delete_embedding_model(model_id: str, _: CurrentUser = Depends(get_admin_user)):
deleted = embedding_model_store.delete_model(model_id)
if not deleted:
raise HTTPException(status_code=404, detail="Embedding model not found")
return {"status": "success"}
@router.post("/embedding-models/test")
def test_embedding_model_connection(payload: EmbeddingModelConnectionTestRequest, _: CurrentUser = Depends(get_admin_user)):
api_base = normalize_openai_base_url(payload.api_base or "")
api_key = payload.api_key
model_name = (payload.model or "").strip()
if not api_base:
raise HTTPException(status_code=400, detail="API Base is required")
if not api_key:
raise HTTPException(status_code=400, detail="API Key is required")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
try:
client = OpenAI(
api_key=api_key,
base_url=api_base,
)
embedding_resp = client.embeddings.create(
model=model_name,
input="connection test",
)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Embedding call failed: {exc}")
dimension = None
if getattr(embedding_resp, "data", None):
first = embedding_resp.data[0]
vector = getattr(first, "embedding", None)
if isinstance(vector, list):
dimension = len(vector)
return {
"success": True,
"message": "Connection successful",
"model_name": model_name,
"embedding_dimension": dimension,
}
+28
View File
@@ -0,0 +1,28 @@
from typing import Optional
from pydantic import BaseModel, Field
class EmbeddingModelConfigBase(BaseModel):
name: str = Field(..., description="Display name for the model configuration")
provider: str = Field("openai", description="Provider type (e.g. openai)")
model: str = Field(..., description="Model name (e.g. text-embedding-3-small)")
api_base: Optional[str] = None
api_key: Optional[str] = None
class EmbeddingModelConfigCreate(EmbeddingModelConfigBase):
pass
class EmbeddingModelConfigUpdate(BaseModel):
name: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
api_base: Optional[str] = None
api_key: Optional[str] = None
class EmbeddingModelConfig(EmbeddingModelConfigBase):
id: str
class EmbeddingModelConnectionTestRequest(BaseModel):
provider: str = Field("openai")
model: str = Field(...)
api_base: Optional[str] = None
api_key: Optional[str] = None
@@ -0,0 +1,77 @@
import json
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from app.core.data_root import get_data_root
class EmbeddingModelStore:
def __init__(self) -> None:
self._lock = threading.RLock()
@staticmethod
def _file_path() -> Path:
return get_data_root() / "embedding_models.json"
def _read(self) -> List[Dict[str, Any]]:
file_path = self._file_path()
if not file_path.exists():
return []
try:
with file_path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return []
if not isinstance(data, list):
return []
return data
def _write(self, data: List[Dict[str, Any]]) -> None:
file_path = self._file_path()
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def list_models(self) -> List[Dict[str, Any]]:
with self._lock:
return self._read()
def get_model(self, model_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
data = self._read()
for item in data:
if item.get("id") == model_id:
return item
return None
def create_model(self, payload: Dict[str, Any]) -> Dict[str, Any]:
with self._lock:
data = self._read()
new_model = payload.copy()
new_model["id"] = uuid.uuid4().hex
data.append(new_model)
self._write(data)
return new_model
def update_model(self, model_id: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
with self._lock:
data = self._read()
for item in data:
if item.get("id") == model_id:
item.update(payload)
self._write(data)
return item
return None
def delete_model(self, model_id: str) -> bool:
with self._lock:
data = self._read()
initial_len = len(data)
data = [item for item in data if item.get("id") != model_id]
if len(data) < initial_len:
self._write(data)
return True
return False
embedding_model_store = EmbeddingModelStore()
+21 -4
View File
@@ -124,10 +124,27 @@ class KnowledgeIndexService:
@staticmethod
def _build_embed_model(kb: Dict[str, Any]) -> Any:
global_config = knowledge_global_config_store.get()
api_base = global_config.get("api_base")
api_key = global_config.get("api_key")
model_name = kb.get("embedding_model") or global_config.get("default_embedding_model")
from app.services.embedding_model_store import embedding_model_store
models = embedding_model_store.list_models()
if not models:
return None
target_model = None
kb_model_val = kb.get("embedding_model")
if kb_model_val:
# Try matching by ID first, then by model name
target_model = next((m for m in models if m.get("id") == kb_model_val), None)
if not target_model:
target_model = next((m for m in models if m.get("model") == kb_model_val), None)
if not target_model:
# Fallback to the first model
target_model = models[0]
api_base = target_model.get("api_base")
api_key = target_model.get("api_key")
model_name = target_model.get("model")
if not api_base or not api_key or not model_name:
return None
api_base = _normalize_embedding_api_base(api_base)
+2 -1
View File
@@ -16,7 +16,7 @@ import re
import os
from datetime import datetime
from app.api import upload, llm, skills, users, datasources, projects, semantic, mcp, subagents, knowledge
from app.api import upload, llm, skills, users, datasources, projects, semantic, mcp, subagents, knowledge, embedding_models
from app.connectors.postgres import postgres_connector
from app.connectors.clickhouse import clickhouse_connector
from app.core.artifacts import extract_artifacts
@@ -71,6 +71,7 @@ app.include_router(semantic.router, prefix="/api/v1")
app.include_router(mcp.router, prefix="/api/v1")
app.include_router(subagents.router, prefix="/api/v1")
app.include_router(knowledge.router, prefix="/api/v1")
app.include_router(embedding_models.router, prefix="/api/v1")
STREAM_DELTA_CHUNK_SIZE = 48
PREVIEWABLE_TEXT_EXTENSIONS = {
+18
View File
@@ -9,6 +9,8 @@ import { Users } from "./pages/Users";
import { Projects } from "./pages/Projects";
import { Login } from "./pages/Login";
import { ModelConfigs } from "./pages/ModelConfigs";
import { EmbeddingModels } from "./pages/EmbeddingModels";
import { KnowledgeBases } from "./pages/KnowledgeBases";
import { DataSources } from "./pages/DataSources";
import { Modeling } from "./pages/Modeling";
import { Subagents } from "./pages/Subagents";
@@ -127,6 +129,22 @@ function App() {
</MainLayout>
</ProtectedRoute>
} />
<Route path="/embedding-models" element={
<ProtectedRoute>
<MainLayout>
<EmbeddingModels />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/knowledge-bases" element={
<ProtectedRoute>
<MainLayout>
<KnowledgeBases />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/datasources" element={
<ProtectedRoute requireAdmin={true}>
+22
View File
@@ -919,6 +919,17 @@ function SidebarBody() {
{t('dataSourceManagement')}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={() => {
navigate("/knowledge-bases");
setShowUserMenu(false);
}}
>
<Database className="h-4 w-4 text-muted-foreground" />
{t('knowledgeBaseManagement', 'Knowledge Bases')}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={() => {
@@ -954,6 +965,17 @@ function SidebarBody() {
{t('modelConfig')}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={() => {
navigate("/embedding-models");
setShowUserMenu(false);
}}
>
<Brain className="h-4 w-4 text-muted-foreground" />
{t('embeddingModelConfig', 'Embedding Models')}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
onClick={() => {
+308
View File
@@ -0,0 +1,308 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { Loader2, Plus, RefreshCw, Search, Trash2, Pencil, Eye, EyeOff, Brain } from "lucide-react";
import { useAuthStore } from "@/store/authStore";
interface EmbeddingModelConfig {
id: string;
name?: string;
provider: string;
model: string;
api_key?: string;
api_base?: string;
}
const defaultForm: Omit<EmbeddingModelConfig, "id"> = {
name: "",
provider: "openai",
model: "",
api_key: "",
api_base: "",
};
export function EmbeddingModels() {
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const [configs, setConfigs] = useState<EmbeddingModelConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState("");
const [form, setForm] = useState<Omit<EmbeddingModelConfig, "id">>(defaultForm);
const fetchConfigs = async () => {
setIsLoading(true);
try {
const data = await api.get<EmbeddingModelConfig[]>("/api/v1/embedding-models");
setConfigs(data);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfigs();
}, []);
const filteredConfigs = useMemo(() => {
const value = keyword.trim().toLowerCase();
if (!value) return configs;
return configs.filter((item) =>
[item.name, item.model, item.provider].filter(Boolean).some((v) => String(v).toLowerCase().includes(value))
);
}, [configs, keyword]);
const openCreate = () => {
setEditingId(null);
setForm(defaultForm);
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const openEdit = (item: EmbeddingModelConfig) => {
setEditingId(item.id);
setForm({
name: item.name || "",
provider: item.provider || "openai",
model: item.model || "",
api_key: item.api_key || "",
api_base: item.api_base || "",
});
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const [isTesting, setIsTesting] = useState(false);
const handleTestConnection = async () => {
if (!form.model || !form.provider) {
setError(t('fillRequiredInfoFirst', 'Please fill required info first'));
return;
}
setIsTesting(true);
setError("");
try {
const payload = {
provider: form.provider,
model: form.model,
api_key: form.api_key,
api_base: form.api_base,
};
await api.post("/api/v1/embedding-models/test", payload);
alert(t('connectionTestSuccessful', 'Connection test successful'));
} catch (e: any) {
setError(e.message || t('connectionTestFailed', 'Connection test failed'));
} finally {
setIsTesting(false);
}
};
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!form.model || !form.provider) {
setError(t('fillRequiredFields', 'Please fill required fields'));
return;
}
setIsSaving(true);
setError("");
try {
const payload = {
...form,
name: form.name || form.model,
};
if (editingId) {
await api.put(`/api/v1/embedding-models/${editingId}`, payload);
} else {
await api.post("/api/v1/embedding-models", payload);
}
setDialogOpen(false);
await fetchConfigs();
} catch (e: any) {
setError(e.message || t('failedToSaveConfig', 'Failed to save config'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm(t('confirmDeleteModel', 'Are you sure to delete this model?'))) return;
try {
await api.delete(`/api/v1/embedding-models/${id}`);
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Brain className="h-5 w-5 text-indigo-500" />{t('embeddingModelConfig', 'Embedding Model Configuration')}</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="h-4 w-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={t('searchModel', 'Search model')} className="w-[200px] pl-9 h-8 text-sm" />
</div>
<Button variant="outline" size="icon" className="h-8 w-8 text-muted-foreground" onClick={fetchConfigs}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground text-sm" onClick={openCreate} disabled={!isAdmin}>
<Plus className="h-4 w-4 mr-1" />{t('addModel', 'Add Model')}</Button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="bg-background rounded-xl border border-border shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('modelName', 'Model Name')}</TableHead>
<TableHead>{t('provider', 'Provider')}</TableHead>
<TableHead>{t('modelIdentifier', 'Model Identifier')}</TableHead>
<TableHead className="text-right">{t('actions', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConfigs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center h-24 text-muted-foreground">{t('noModelData', 'No model data')}</TableCell>
</TableRow>
) : (
filteredConfigs.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.name || item.model}
</TableCell>
<TableCell className="capitalize">{item.provider}</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">{item.model}</TableCell>
<TableCell className="text-right">
{isAdmin && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-indigo-600"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-red-600"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSave}>
<DialogHeader>
<DialogTitle>{editingId ? t('editModel', 'Edit Model') : t('addModel', 'Add Model')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelName', 'Model Name')}</Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder={t('egTextEmbedding3Small', 'e.g. text-embedding-3-small')} />
</div>
<div className="space-y-2">
<Label>{t('providerRequired', 'Provider (Required)')}</Label>
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="local">Local (OpenAI Compatible)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelIdRequired', 'Model ID (Required)')}</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="text-embedding-3-small" required />
</div>
<div className="space-y-2">
<Label>{t('apiDomain', 'API Base URL')}</Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder={t('egApiDomain', 'e.g. https://api.openai.com/v1')} />
</div>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showApiKey ? "text" : "password"}
value={form.api_key || ""}
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
className="pr-10"
placeholder={t('leaveBlankIfNotModifying', 'Leave blank if not modifying')}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground"
onClick={() => setShowApiKey((v) => !v)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<DialogFooter className="flex items-center justify-between gap-2">
<Button type="button" variant="outline" onClick={handleTestConnection} disabled={isTesting}>
{isTesting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('testConnection', 'Test Connection')}
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('save', 'Save')}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+742
View File
@@ -0,0 +1,742 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Save, Loader2, Database, RefreshCw, Pencil, Trash2, FileText, Plus } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
interface KnowledgeBase {
id: string;
name: string;
description?: string;
project_id?: number | null;
embedding_model?: string | null;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
updated_at: string;
documents?: Array<{ id: string }>;
}
interface KnowledgeBaseForm {
name: string;
description: string;
embedding_model: string;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
}
interface KnowledgeDocument {
id: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface EmbeddingModelConfig {
id: string;
name?: string;
provider: string;
model: string;
}
const defaultKnowledgeBaseForm: KnowledgeBaseForm = {
name: '',
description: '',
embedding_model: '',
chunk_size: 512,
chunk_overlap: 50,
top_k: 3,
is_active: true,
};
const defaultKnowledgeDocumentForm = {
title: '',
content: '',
metadata: '',
};
export function KnowledgeBases() {
const { t } = useTranslation();
const { currentProject } = useProjectStore();
const [isLoadingKnowledgeBases, setIsLoadingKnowledgeBases] = useState(false);
const [isSavingKnowledgeBase, setIsSavingKnowledgeBase] = useState(false);
const [deletingKnowledgeBaseId, setDeletingKnowledgeBaseId] = useState('');
const [reindexingKnowledgeBaseId, setReindexingKnowledgeBaseId] = useState('');
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [editingKnowledgeBaseId, setEditingKnowledgeBaseId] = useState('');
const [knowledgeBaseForm, setKnowledgeBaseForm] = useState<KnowledgeBaseForm>(defaultKnowledgeBaseForm);
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState('');
const [knowledgeDocuments, setKnowledgeDocuments] = useState<KnowledgeDocument[]>([]);
const [isLoadingKnowledgeDocuments, setIsLoadingKnowledgeDocuments] = useState(false);
const [isSavingKnowledgeDocument, setIsSavingKnowledgeDocument] = useState(false);
const [deletingKnowledgeDocumentId, setDeletingKnowledgeDocumentId] = useState('');
const [editingKnowledgeDocumentId, setEditingKnowledgeDocumentId] = useState('');
const [knowledgeDocumentForm, setKnowledgeDocumentForm] = useState(defaultKnowledgeDocumentForm);
const [uploadingKnowledgeDocuments, setUploadingKnowledgeDocuments] = useState(false);
const [knowledgeUploadFiles, setKnowledgeUploadFiles] = useState<File[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModelConfig[]>([]);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
void fetchKnowledgeBases();
}, [currentProject?.id]);
useEffect(() => {
void fetchEmbeddingModels();
}, []);
const fetchEmbeddingModels = async () => {
try {
const data = await api.get<EmbeddingModelConfig[]>('/api/v1/embedding-models');
setEmbeddingModels(data);
} catch (err) {
console.error('Failed to fetch embedding models', err);
}
};
const fetchKnowledgeBases = async () => {
if (!currentProject) {
setKnowledgeBases([]);
return;
}
setIsLoadingKnowledgeBases(true);
try {
const data = await api.get<KnowledgeBase[]>(`/api/v1/knowledge-bases?project_id=${currentProject.id}`);
setKnowledgeBases(data);
if (editingKnowledgeBaseId && !data.find((item) => item.id === editingKnowledgeBaseId)) {
setEditingKnowledgeBaseId('');
setKnowledgeBaseForm(defaultKnowledgeBaseForm);
}
if (selectedKnowledgeBaseId && !data.find((item) => item.id === selectedKnowledgeBaseId)) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
}
} catch (err: any) {
setError(err.message || t('knowledgeBaseLoadFailed', 'Failed to load knowledge bases'));
} finally {
setIsLoadingKnowledgeBases(false);
}
};
const resetKnowledgeBaseForm = () => {
setEditingKnowledgeBaseId('');
setKnowledgeBaseForm(defaultKnowledgeBaseForm);
};
const validateKnowledgeBaseForm = () => {
if (!currentProject) {
return t('selectProjectBeforeManageKnowledgeBase', 'Please select a project before managing knowledge bases');
}
if (!knowledgeBaseForm.name.trim()) {
return t('knowledgeBaseNameRequired', 'Knowledge base name is required');
}
if (knowledgeBaseForm.chunk_size < 64 || knowledgeBaseForm.chunk_size > 4096) {
return t('knowledgeBaseChunkSizeRange', 'Chunk size must be between 64 and 4096');
}
if (knowledgeBaseForm.chunk_overlap < 0 || knowledgeBaseForm.chunk_overlap > 512) {
return t('knowledgeBaseChunkOverlapRange', 'Chunk overlap must be between 0 and 512');
}
if (knowledgeBaseForm.chunk_overlap >= knowledgeBaseForm.chunk_size) {
return t('knowledgeBaseChunkOverlapTooLarge', 'Chunk overlap must be less than chunk size');
}
if (knowledgeBaseForm.top_k < 1 || knowledgeBaseForm.top_k > 20) {
return t('knowledgeBaseTopKRange', 'Top K must be between 1 and 20');
}
return '';
};
const handleSaveKnowledgeBase = async () => {
setError('');
setSuccess('');
const validationMessage = validateKnowledgeBaseForm();
if (validationMessage) {
setError(validationMessage);
return;
}
if (!currentProject) return;
setIsSavingKnowledgeBase(true);
try {
const payload = {
name: knowledgeBaseForm.name.trim(),
description: knowledgeBaseForm.description.trim() || null,
embedding_model: knowledgeBaseForm.embedding_model.trim() || null,
chunk_size: knowledgeBaseForm.chunk_size,
chunk_overlap: knowledgeBaseForm.chunk_overlap,
top_k: knowledgeBaseForm.top_k,
is_active: knowledgeBaseForm.is_active,
project_id: currentProject.id,
};
if (editingKnowledgeBaseId) {
await api.put(`/api/v1/knowledge-bases/${editingKnowledgeBaseId}`, payload);
setSuccess(t('knowledgeBaseUpdated', 'Knowledge base updated successfully'));
} else {
await api.post('/api/v1/knowledge-bases', payload);
setSuccess(t('knowledgeBaseCreated', 'Knowledge base created successfully'));
}
await fetchKnowledgeBases();
resetKnowledgeBaseForm();
} catch (err: any) {
setError(err.message || t('knowledgeBaseSaveFailed', 'Failed to save knowledge base'));
} finally {
setIsSavingKnowledgeBase(false);
}
};
const handleEditKnowledgeBase = (item: KnowledgeBase) => {
setEditingKnowledgeBaseId(item.id);
setKnowledgeBaseForm({
name: item.name || '',
description: item.description || '',
embedding_model: item.embedding_model || '',
chunk_size: item.chunk_size,
chunk_overlap: item.chunk_overlap,
top_k: item.top_k,
is_active: item.is_active,
});
};
const handleDeleteKnowledgeBase = async (id: string) => {
if (!window.confirm(t('confirmDeleteKnowledgeBase', 'Are you sure to delete this knowledge base?'))) {
return;
}
setError('');
setSuccess('');
setDeletingKnowledgeBaseId(id);
try {
await api.delete(`/api/v1/knowledge-bases/${id}`);
setSuccess(t('knowledgeBaseDeleted', 'Knowledge base deleted successfully'));
if (editingKnowledgeBaseId === id) {
resetKnowledgeBaseForm();
}
if (selectedKnowledgeBaseId === id) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
}
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeBaseDeleteFailed', 'Failed to delete knowledge base'));
} finally {
setDeletingKnowledgeBaseId('');
}
};
const handleReindexKnowledgeBase = async (id: string) => {
setError('');
setSuccess('');
setReindexingKnowledgeBaseId(id);
try {
await api.post(`/api/v1/knowledge-bases/${id}/reindex`, {});
setSuccess(t('knowledgeBaseReindexSuccess', 'Knowledge base reindexed successfully'));
} catch (err: any) {
setError(err.message || t('knowledgeBaseReindexFailed', 'Failed to reindex knowledge base'));
} finally {
setReindexingKnowledgeBaseId('');
}
};
const resetKnowledgeDocumentForm = () => {
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
};
const fetchKnowledgeDocuments = async (kbId: string) => {
if (!kbId) {
setKnowledgeDocuments([]);
return;
}
setIsLoadingKnowledgeDocuments(true);
try {
const data = await api.get<KnowledgeDocument[]>(`/api/v1/knowledge-bases/${kbId}/documents`);
setKnowledgeDocuments(data);
if (editingKnowledgeDocumentId && !data.find((item) => item.id === editingKnowledgeDocumentId)) {
resetKnowledgeDocumentForm();
}
} catch (err: any) {
setError(err.message || t('knowledgeDocumentLoadFailed', 'Failed to load knowledge documents'));
} finally {
setIsLoadingKnowledgeDocuments(false);
}
};
const handleOpenKnowledgeDocuments = async (kbId: string) => {
if (selectedKnowledgeBaseId === kbId) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
resetKnowledgeDocumentForm();
return;
}
setSelectedKnowledgeBaseId(kbId);
resetKnowledgeDocumentForm();
await fetchKnowledgeDocuments(kbId);
};
const validateKnowledgeDocumentForm = () => {
if (!selectedKnowledgeBaseId) {
return t('selectKnowledgeBaseToManageDocuments', 'Please select a knowledge base to manage documents');
}
if (!knowledgeDocumentForm.title.trim()) {
return t('knowledgeDocumentTitleRequired', 'Document title is required');
}
if (!knowledgeDocumentForm.content.trim()) {
return t('knowledgeDocumentContentRequired', 'Document content is required');
}
const metadataText = knowledgeDocumentForm.metadata.trim();
if (!metadataText) {
return '';
}
try {
JSON.parse(metadataText);
return '';
} catch {
return t('knowledgeDocumentMetadataInvalid', 'Document metadata must be valid JSON');
}
};
const handleSaveKnowledgeDocument = async () => {
setError('');
setSuccess('');
const validationMessage = validateKnowledgeDocumentForm();
if (validationMessage) {
setError(validationMessage);
return;
}
if (!selectedKnowledgeBaseId) return;
setIsSavingKnowledgeDocument(true);
try {
const metadataText = knowledgeDocumentForm.metadata.trim();
const payload = {
title: knowledgeDocumentForm.title.trim(),
content: knowledgeDocumentForm.content.trim(),
metadata: metadataText ? JSON.parse(metadataText) : {},
};
if (editingKnowledgeDocumentId) {
await api.put(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/${editingKnowledgeDocumentId}`, payload);
setSuccess(t('knowledgeDocumentUpdated', 'Knowledge document updated successfully'));
} else {
await api.post(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents`, payload);
setSuccess(t('knowledgeDocumentCreated', 'Knowledge document created successfully'));
}
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
resetKnowledgeDocumentForm();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentSaveFailed', 'Failed to save knowledge document'));
} finally {
setIsSavingKnowledgeDocument(false);
}
};
const handleEditKnowledgeDocument = (item: KnowledgeDocument) => {
setEditingKnowledgeDocumentId(item.id);
setKnowledgeDocumentForm({
title: item.title || '',
content: item.content || '',
metadata: item.metadata && Object.keys(item.metadata).length > 0 ? JSON.stringify(item.metadata, null, 2) : '',
});
};
const handleDeleteKnowledgeDocument = async (docId: string) => {
if (!selectedKnowledgeBaseId) return;
if (!window.confirm(t('confirmDeleteKnowledgeDocument', 'Are you sure to delete this document?'))) {
return;
}
setError('');
setSuccess('');
setDeletingKnowledgeDocumentId(docId);
try {
await api.delete(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/${docId}`);
if (editingKnowledgeDocumentId === docId) {
resetKnowledgeDocumentForm();
}
setSuccess(t('knowledgeDocumentDeleted', 'Knowledge document deleted successfully'));
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentDeleteFailed', 'Failed to delete knowledge document'));
} finally {
setDeletingKnowledgeDocumentId('');
}
};
const handleUploadKnowledgeDocuments = async () => {
setError('');
setSuccess('');
if (!selectedKnowledgeBaseId) {
setError(t('selectKnowledgeBaseToManageDocuments', 'Please select a knowledge base to manage documents'));
return;
}
if (knowledgeUploadFiles.length === 0) {
setError(t('knowledgeDocumentUploadEmpty', 'Please select files to upload'));
return;
}
setUploadingKnowledgeDocuments(true);
try {
const formData = new FormData();
knowledgeUploadFiles.forEach((file) => formData.append('files', file));
await api.post(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/upload`, formData);
setSuccess(t('knowledgeDocumentUploadSuccess', { count: knowledgeUploadFiles.length }));
setKnowledgeUploadFiles([]);
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentUploadFailed', 'Failed to upload knowledge documents'));
} finally {
setUploadingKnowledgeDocuments(false);
}
};
const selectedKnowledgeBase = knowledgeBases.find((item) => item.id === selectedKnowledgeBaseId) || null;
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Database className="h-5 w-5 text-indigo-500" />
{t('knowledgeBaseManagement', 'Knowledge Base Management')}
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="grid gap-6 max-w-4xl mx-auto">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Database className="h-5 w-5 text-indigo-500" />
{t('knowledgeBaseSettings')}
</CardTitle>
<CardDescription>{t('knowledgeBaseSettingsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!currentProject ? (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-3">
{t('selectProjectBeforeManageKnowledgeBase')}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-name">{t('knowledgeBaseName')}</Label>
<Input
id="knowledge-base-name"
value={knowledgeBaseForm.name}
placeholder={t('knowledgeBaseNamePlaceholder')}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-description">{t('description')}</Label>
<Input
id="knowledge-base-description"
value={knowledgeBaseForm.description}
placeholder={t('knowledgeBaseDescriptionPlaceholder')}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, description: e.target.value }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-embedding-model">{t('knowledgeBaseEmbeddingModel')}</Label>
<Select
value={knowledgeBaseForm.embedding_model}
onValueChange={(val) => setKnowledgeBaseForm((prev) => ({ ...prev, embedding_model: val || '' }))}
disabled={!currentProject}
>
<SelectTrigger id="knowledge-base-embedding-model">
<SelectValue placeholder={t('knowledgeBaseEmbeddingModelPlaceholder', 'Select an embedding model')} />
</SelectTrigger>
<SelectContent>
{embeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.model} ({model.provider})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-size">{t('knowledgeBaseChunkSize')}</Label>
<Input
id="knowledge-base-chunk-size"
type="number"
value={knowledgeBaseForm.chunk_size}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, chunk_size: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-overlap">{t('knowledgeBaseChunkOverlap')}</Label>
<Input
id="knowledge-base-chunk-overlap"
type="number"
value={knowledgeBaseForm.chunk_overlap}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, chunk_overlap: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-top-k">{t('knowledgeBaseTopK')}</Label>
<Input
id="knowledge-base-top-k"
type="number"
value={knowledgeBaseForm.top_k}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, top_k: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2 mt-7">
<Label htmlFor="knowledge-base-active">{t('activeStatus')}</Label>
<Switch
id="knowledge-base-active"
checked={knowledgeBaseForm.is_active}
onCheckedChange={(checked) => setKnowledgeBaseForm((prev) => ({ ...prev, is_active: checked }))}
disabled={!currentProject}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editingKnowledgeBaseId ? (
<Button variant="outline" onClick={resetKnowledgeBaseForm} disabled={isSavingKnowledgeBase}>
{t('cancel')}
</Button>
) : null}
<Button onClick={handleSaveKnowledgeBase} disabled={!currentProject || isSavingKnowledgeBase}>
{isSavingKnowledgeBase ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{editingKnowledgeBaseId ? t('updateKnowledgeBase') : t('createKnowledgeBase')}
</Button>
</div>
<div className="border-t border-border pt-4 space-y-3">
<div className="font-medium text-sm text-foreground/80">{t('knowledgeBaseList')}</div>
{isLoadingKnowledgeBases ? (
<div className="h-20 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : knowledgeBases.length === 0 ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('noKnowledgeBases')}
</div>
) : (
<div className="space-y-2">
{knowledgeBases.map((item) => (
<div key={item.id} className="rounded-lg border border-border p-3 flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="font-medium text-sm text-foreground truncate">{item.name}</div>
<div className="text-xs text-muted-foreground">
{t('knowledgeBaseMeta', {
count: item.documents?.length || 0,
updatedAt: new Date(item.updated_at).toLocaleString(),
})}
</div>
{item.description ? (
<div className="text-xs text-muted-foreground break-words">{item.description}</div>
) : null}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleOpenKnowledgeDocuments(item.id);
}}
title={t('manageKnowledgeDocuments')}
>
{selectedKnowledgeBaseId === item.id ? <Plus className="h-4 w-4 text-indigo-500" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={() => handleEditKnowledgeBase(item)} title={t('editKnowledgeBase')}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleReindexKnowledgeBase(item.id);
}}
disabled={reindexingKnowledgeBaseId === item.id}
title={t('reindexKnowledgeBase')}
>
{reindexingKnowledgeBaseId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleDeleteKnowledgeBase(item.id);
}}
disabled={deletingKnowledgeBaseId === item.id}
title={t('deleteKnowledgeBase')}
>
{deletingKnowledgeBaseId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4 text-red-500" />}
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="border-t border-border pt-4 space-y-3">
<div className="font-medium text-sm text-foreground/80">
{selectedKnowledgeBase
? t('knowledgeDocumentManagerTitle', { name: selectedKnowledgeBase.name })
: t('knowledgeDocumentManagerTitleEmpty')}
</div>
{!selectedKnowledgeBase ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('selectKnowledgeBaseToManageDocuments')}
</div>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="text-sm font-medium text-foreground">{t('knowledgeDocumentUploadTitle')}</div>
<Input
type="file"
multiple
onChange={(e) => setKnowledgeUploadFiles(Array.from(e.target.files || []))}
disabled={uploadingKnowledgeDocuments}
/>
<div className="text-xs text-muted-foreground">
{t('knowledgeDocumentUploadHint')}
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{knowledgeUploadFiles.length > 0
? t('knowledgeDocumentUploadSelected', { count: knowledgeUploadFiles.length })
: t('knowledgeDocumentUploadNone')}
</div>
<Button onClick={handleUploadKnowledgeDocuments} disabled={uploadingKnowledgeDocuments || knowledgeUploadFiles.length === 0}>
{uploadingKnowledgeDocuments ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
{t('knowledgeDocumentUploadAction')}
</Button>
</div>
</div>
<div className="grid gap-3">
<div className="space-y-2">
<Label htmlFor="knowledge-doc-title">{t('knowledgeDocumentTitle')}</Label>
<Input
id="knowledge-doc-title"
value={knowledgeDocumentForm.title}
placeholder={t('knowledgeDocumentTitlePlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, title: e.target.value }))}
disabled={isSavingKnowledgeDocument}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-doc-content">{t('knowledgeDocumentContent')}</Label>
<Textarea
id="knowledge-doc-content"
value={knowledgeDocumentForm.content}
placeholder={t('knowledgeDocumentContentPlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, content: e.target.value }))}
disabled={isSavingKnowledgeDocument}
rows={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-doc-metadata">{t('knowledgeDocumentMetadata')}</Label>
<Textarea
id="knowledge-doc-metadata"
value={knowledgeDocumentForm.metadata}
placeholder={t('knowledgeDocumentMetadataPlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, metadata: e.target.value }))}
disabled={isSavingKnowledgeDocument}
rows={3}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editingKnowledgeDocumentId ? (
<Button variant="outline" onClick={resetKnowledgeDocumentForm} disabled={isSavingKnowledgeDocument}>
{t('cancel')}
</Button>
) : null}
<Button onClick={handleSaveKnowledgeDocument} disabled={isSavingKnowledgeDocument}>
{isSavingKnowledgeDocument ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{editingKnowledgeDocumentId ? t('updateKnowledgeDocument') : t('createKnowledgeDocument')}
</Button>
</div>
{isLoadingKnowledgeDocuments ? (
<div className="h-20 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : knowledgeDocuments.length === 0 ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('noKnowledgeDocuments')}
</div>
) : (
<div className="space-y-2">
{knowledgeDocuments.map((doc) => (
<div key={doc.id} className="rounded-lg border border-border p-3 flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="font-medium text-sm text-foreground truncate">{doc.title}</div>
<div className="text-xs text-muted-foreground">
{t('knowledgeDocumentMeta', {
updatedAt: new Date(doc.updated_at).toLocaleString(),
})}
</div>
<div className="text-xs text-muted-foreground break-words">{doc.content.slice(0, 120)}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => handleEditKnowledgeDocument(doc)} title={t('editKnowledgeDocument')}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleDeleteKnowledgeDocument(doc.id);
}}
disabled={deletingKnowledgeDocumentId === doc.id}
title={t('deleteKnowledgeDocument')}
>
{deletingKnowledgeDocumentId === doc.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4 text-red-500" />}
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</CardContent>
<CardFooter className="bg-muted/50/50 border-t border-border pt-6">
<Button variant="outline" onClick={() => void fetchKnowledgeBases()} disabled={!currentProject || isLoadingKnowledgeBases} className="ml-auto">
{isLoadingKnowledgeBases ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
{t('refreshKnowledgeBaseList')}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}
+2 -920
View File
@@ -4,119 +4,17 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Save, Loader2, Database, RefreshCw, Pencil, Trash2, FileText, Plus } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { api } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
interface KnowledgeBase {
id: string;
name: string;
description?: string;
project_id?: number | null;
embedding_model?: string | null;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
updated_at: string;
documents?: Array<{ id: string }>;
}
interface KnowledgeBaseForm {
name: string;
description: string;
embedding_model: string;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
}
interface KnowledgeDocument {
id: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface KnowledgeGlobalConfig {
api_base?: string | null;
api_key?: string | null;
api_key_masked?: string | null;
has_api_key: boolean;
default_embedding_model?: string | null;
}
interface KnowledgeConnectionTestResult {
success: boolean;
message: string;
model_name?: string | null;
embedding_dimension?: number | null;
resolved_api_base?: string | null;
available_models?: string[];
}
const defaultKnowledgeBaseForm: KnowledgeBaseForm = {
name: '',
description: '',
embedding_model: '',
chunk_size: 512,
chunk_overlap: 50,
top_k: 3,
is_active: true,
};
const defaultKnowledgeDocumentForm = {
title: '',
content: '',
metadata: '',
};
export function Settings() {
const { t } = useTranslation();
const { user, updateUser } = useAuthStore();
const { currentProject } = useProjectStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isLoadingKnowledgeBases, setIsLoadingKnowledgeBases] = useState(false);
const [isSavingKnowledgeBase, setIsSavingKnowledgeBase] = useState(false);
const [deletingKnowledgeBaseId, setDeletingKnowledgeBaseId] = useState('');
const [reindexingKnowledgeBaseId, setReindexingKnowledgeBaseId] = useState('');
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [editingKnowledgeBaseId, setEditingKnowledgeBaseId] = useState('');
const [knowledgeBaseForm, setKnowledgeBaseForm] = useState<KnowledgeBaseForm>(defaultKnowledgeBaseForm);
const [knowledgeGlobalConfig, setKnowledgeGlobalConfig] = useState<KnowledgeGlobalConfig>({
api_base: '',
api_key: null,
api_key_masked: null,
has_api_key: false,
default_embedding_model: '',
});
const [knowledgeGlobalForm, setKnowledgeGlobalForm] = useState({
api_base: '',
api_key: '',
default_embedding_model: '',
});
const [isLoadingKnowledgeGlobalConfig, setIsLoadingKnowledgeGlobalConfig] = useState(false);
const [isSavingKnowledgeGlobalConfig, setIsSavingKnowledgeGlobalConfig] = useState(false);
const [isTestingKnowledgeGlobalConnection, setIsTestingKnowledgeGlobalConnection] = useState(false);
const [knowledgeConnectionTestResult, setKnowledgeConnectionTestResult] = useState<KnowledgeConnectionTestResult | null>(null);
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState('');
const [knowledgeDocuments, setKnowledgeDocuments] = useState<KnowledgeDocument[]>([]);
const [isLoadingKnowledgeDocuments, setIsLoadingKnowledgeDocuments] = useState(false);
const [isSavingKnowledgeDocument, setIsSavingKnowledgeDocument] = useState(false);
const [deletingKnowledgeDocumentId, setDeletingKnowledgeDocumentId] = useState('');
const [editingKnowledgeDocumentId, setEditingKnowledgeDocumentId] = useState('');
const [knowledgeDocumentForm, setKnowledgeDocumentForm] = useState(defaultKnowledgeDocumentForm);
const [uploadingKnowledgeDocuments, setUploadingKnowledgeDocuments] = useState(false);
const [knowledgeUploadFiles, setKnowledgeUploadFiles] = useState<File[]>([]);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
@@ -126,429 +24,8 @@ export function Settings() {
}
}, [user]);
useEffect(() => {
void fetchKnowledgeBases();
}, [currentProject?.id]);
useEffect(() => {
void fetchKnowledgeGlobalConfig();
}, []);
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
const fetchKnowledgeGlobalConfig = async () => {
setIsLoadingKnowledgeGlobalConfig(true);
try {
const data = await api.get<KnowledgeGlobalConfig>('/api/v1/knowledge-bases/global-config');
setKnowledgeGlobalConfig(data);
setKnowledgeGlobalForm({
api_base: data.api_base || '',
api_key: '',
default_embedding_model: data.default_embedding_model || '',
});
} catch (err: any) {
setError(err.message || t('knowledgeGlobalConfigLoadFailed'));
} finally {
setIsLoadingKnowledgeGlobalConfig(false);
}
};
const validateKnowledgeGlobalConfig = () => {
const normalizedApiBase = knowledgeGlobalForm.api_base.trim();
if (!normalizedApiBase) {
return '';
}
if (!(normalizedApiBase.startsWith('http://') || normalizedApiBase.startsWith('https://'))) {
return t('knowledgeGlobalConfigApiBaseInvalid');
}
if (normalizedApiBase.toLowerCase().endsWith('/embeddings')) {
return t('knowledgeGlobalConfigApiBaseShouldBeBaseUrl');
}
return '';
};
const validateKnowledgeGlobalModelName = () => {
const normalizedModelName = knowledgeGlobalForm.default_embedding_model.trim();
if (!normalizedModelName) {
return '';
}
if (normalizedModelName.length > 200) {
return t('knowledgeGlobalModelNameTooLong');
}
return '';
};
const handleSaveKnowledgeGlobalConfig = async () => {
setError('');
setSuccess('');
const validationMessage = validateKnowledgeGlobalConfig();
if (validationMessage) {
setError(validationMessage);
return;
}
const modelValidationMessage = validateKnowledgeGlobalModelName();
if (modelValidationMessage) {
setError(modelValidationMessage);
return;
}
setIsSavingKnowledgeGlobalConfig(true);
try {
const payload: Record<string, string | null> = {
api_base: knowledgeGlobalForm.api_base.trim() || null,
default_embedding_model: knowledgeGlobalForm.default_embedding_model.trim() || null,
};
const normalizedApiKey = knowledgeGlobalForm.api_key.trim();
if (normalizedApiKey) {
payload.api_key = normalizedApiKey;
}
const data = await api.put<KnowledgeGlobalConfig>('/api/v1/knowledge-bases/global-config', payload);
setKnowledgeGlobalConfig(data);
setKnowledgeGlobalForm({
api_base: data.api_base || '',
api_key: '',
default_embedding_model: data.default_embedding_model || '',
});
setKnowledgeConnectionTestResult(null);
setSuccess(t('knowledgeGlobalConfigSaved'));
} catch (err: any) {
setError(err.message || t('knowledgeGlobalConfigSaveFailed'));
} finally {
setIsSavingKnowledgeGlobalConfig(false);
}
};
const handleTestKnowledgeGlobalConnection = async () => {
setError('');
setSuccess('');
setKnowledgeConnectionTestResult(null);
const validationMessage = validateKnowledgeGlobalConfig();
if (validationMessage) {
setError(validationMessage);
return;
}
const modelValidationMessage = validateKnowledgeGlobalModelName();
if (modelValidationMessage) {
setError(modelValidationMessage);
return;
}
const normalizedModelName = knowledgeGlobalForm.default_embedding_model.trim();
if (!normalizedModelName) {
setError(t('knowledgeGlobalModelNameRequiredForTest'));
return;
}
setIsTestingKnowledgeGlobalConnection(true);
try {
const payload: Record<string, string> = {};
const normalizedApiBase = knowledgeGlobalForm.api_base.trim();
const normalizedApiKey = knowledgeGlobalForm.api_key.trim();
if (normalizedApiBase) payload.api_base = normalizedApiBase;
if (normalizedApiKey) payload.api_key = normalizedApiKey;
if (normalizedModelName) payload.model_name = normalizedModelName;
const result = await api.post<KnowledgeConnectionTestResult>('/api/v1/knowledge-bases/global-config/test-connection', payload);
setKnowledgeConnectionTestResult(result);
setSuccess(t('knowledgeGlobalConnectionTestPassed'));
} catch (err: any) {
setError(err.message || t('knowledgeGlobalConnectionTestFailed'));
} finally {
setIsTestingKnowledgeGlobalConnection(false);
}
};
const fetchKnowledgeBases = async () => {
if (!currentProject) {
setKnowledgeBases([]);
return;
}
setIsLoadingKnowledgeBases(true);
try {
const data = await api.get<KnowledgeBase[]>(`/api/v1/knowledge-bases?project_id=${currentProject.id}`);
setKnowledgeBases(data);
if (editingKnowledgeBaseId && !data.find((item) => item.id === editingKnowledgeBaseId)) {
setEditingKnowledgeBaseId('');
setKnowledgeBaseForm(defaultKnowledgeBaseForm);
}
if (selectedKnowledgeBaseId && !data.find((item) => item.id === selectedKnowledgeBaseId)) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
}
} catch (err: any) {
setError(err.message || t('knowledgeBaseLoadFailed'));
} finally {
setIsLoadingKnowledgeBases(false);
}
};
const resetKnowledgeBaseForm = () => {
setEditingKnowledgeBaseId('');
setKnowledgeBaseForm(defaultKnowledgeBaseForm);
};
const validateKnowledgeBaseForm = () => {
if (!currentProject) {
return t('selectProjectBeforeManageKnowledgeBase');
}
if (!knowledgeBaseForm.name.trim()) {
return t('knowledgeBaseNameRequired');
}
if (knowledgeBaseForm.chunk_size < 64 || knowledgeBaseForm.chunk_size > 4096) {
return t('knowledgeBaseChunkSizeRange');
}
if (knowledgeBaseForm.chunk_overlap < 0 || knowledgeBaseForm.chunk_overlap > 512) {
return t('knowledgeBaseChunkOverlapRange');
}
if (knowledgeBaseForm.chunk_overlap >= knowledgeBaseForm.chunk_size) {
return t('knowledgeBaseChunkOverlapTooLarge');
}
if (knowledgeBaseForm.top_k < 1 || knowledgeBaseForm.top_k > 20) {
return t('knowledgeBaseTopKRange');
}
return '';
};
const handleSaveKnowledgeBase = async () => {
setError('');
setSuccess('');
const validationMessage = validateKnowledgeBaseForm();
if (validationMessage) {
setError(validationMessage);
return;
}
if (!currentProject) return;
setIsSavingKnowledgeBase(true);
try {
const payload = {
name: knowledgeBaseForm.name.trim(),
description: knowledgeBaseForm.description.trim() || null,
embedding_model: knowledgeBaseForm.embedding_model.trim() || null,
chunk_size: knowledgeBaseForm.chunk_size,
chunk_overlap: knowledgeBaseForm.chunk_overlap,
top_k: knowledgeBaseForm.top_k,
is_active: knowledgeBaseForm.is_active,
project_id: currentProject.id,
};
if (editingKnowledgeBaseId) {
await api.put(`/api/v1/knowledge-bases/${editingKnowledgeBaseId}`, payload);
setSuccess(t('knowledgeBaseUpdated'));
} else {
await api.post('/api/v1/knowledge-bases', payload);
setSuccess(t('knowledgeBaseCreated'));
}
await fetchKnowledgeBases();
resetKnowledgeBaseForm();
} catch (err: any) {
setError(err.message || t('knowledgeBaseSaveFailed'));
} finally {
setIsSavingKnowledgeBase(false);
}
};
const handleEditKnowledgeBase = (item: KnowledgeBase) => {
setEditingKnowledgeBaseId(item.id);
setKnowledgeBaseForm({
name: item.name || '',
description: item.description || '',
embedding_model: item.embedding_model || '',
chunk_size: item.chunk_size,
chunk_overlap: item.chunk_overlap,
top_k: item.top_k,
is_active: item.is_active,
});
};
const handleDeleteKnowledgeBase = async (id: string) => {
if (!window.confirm(t('confirmDeleteKnowledgeBase'))) {
return;
}
setError('');
setSuccess('');
setDeletingKnowledgeBaseId(id);
try {
await api.delete(`/api/v1/knowledge-bases/${id}`);
setSuccess(t('knowledgeBaseDeleted'));
if (editingKnowledgeBaseId === id) {
resetKnowledgeBaseForm();
}
if (selectedKnowledgeBaseId === id) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
}
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeBaseDeleteFailed'));
} finally {
setDeletingKnowledgeBaseId('');
}
};
const handleReindexKnowledgeBase = async (id: string) => {
setError('');
setSuccess('');
setReindexingKnowledgeBaseId(id);
try {
await api.post(`/api/v1/knowledge-bases/${id}/reindex`, {});
setSuccess(t('knowledgeBaseReindexSuccess'));
} catch (err: any) {
setError(err.message || t('knowledgeBaseReindexFailed'));
} finally {
setReindexingKnowledgeBaseId('');
}
};
const resetKnowledgeDocumentForm = () => {
setEditingKnowledgeDocumentId('');
setKnowledgeDocumentForm(defaultKnowledgeDocumentForm);
};
const fetchKnowledgeDocuments = async (kbId: string) => {
if (!kbId) {
setKnowledgeDocuments([]);
return;
}
setIsLoadingKnowledgeDocuments(true);
try {
const data = await api.get<KnowledgeDocument[]>(`/api/v1/knowledge-bases/${kbId}/documents`);
setKnowledgeDocuments(data);
if (editingKnowledgeDocumentId && !data.find((item) => item.id === editingKnowledgeDocumentId)) {
resetKnowledgeDocumentForm();
}
} catch (err: any) {
setError(err.message || t('knowledgeDocumentLoadFailed'));
} finally {
setIsLoadingKnowledgeDocuments(false);
}
};
const handleOpenKnowledgeDocuments = async (kbId: string) => {
if (selectedKnowledgeBaseId === kbId) {
setSelectedKnowledgeBaseId('');
setKnowledgeDocuments([]);
resetKnowledgeDocumentForm();
return;
}
setSelectedKnowledgeBaseId(kbId);
resetKnowledgeDocumentForm();
await fetchKnowledgeDocuments(kbId);
};
const validateKnowledgeDocumentForm = () => {
if (!selectedKnowledgeBaseId) {
return t('selectKnowledgeBaseToManageDocuments');
}
if (!knowledgeDocumentForm.title.trim()) {
return t('knowledgeDocumentTitleRequired');
}
if (!knowledgeDocumentForm.content.trim()) {
return t('knowledgeDocumentContentRequired');
}
const metadataText = knowledgeDocumentForm.metadata.trim();
if (!metadataText) {
return '';
}
try {
JSON.parse(metadataText);
return '';
} catch {
return t('knowledgeDocumentMetadataInvalid');
}
};
const handleSaveKnowledgeDocument = async () => {
setError('');
setSuccess('');
const validationMessage = validateKnowledgeDocumentForm();
if (validationMessage) {
setError(validationMessage);
return;
}
if (!selectedKnowledgeBaseId) return;
setIsSavingKnowledgeDocument(true);
try {
const metadataText = knowledgeDocumentForm.metadata.trim();
const payload = {
title: knowledgeDocumentForm.title.trim(),
content: knowledgeDocumentForm.content.trim(),
metadata: metadataText ? JSON.parse(metadataText) : {},
};
if (editingKnowledgeDocumentId) {
await api.put(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/${editingKnowledgeDocumentId}`, payload);
setSuccess(t('knowledgeDocumentUpdated'));
} else {
await api.post(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents`, payload);
setSuccess(t('knowledgeDocumentCreated'));
}
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
resetKnowledgeDocumentForm();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentSaveFailed'));
} finally {
setIsSavingKnowledgeDocument(false);
}
};
const handleEditKnowledgeDocument = (item: KnowledgeDocument) => {
setEditingKnowledgeDocumentId(item.id);
setKnowledgeDocumentForm({
title: item.title || '',
content: item.content || '',
metadata: item.metadata && Object.keys(item.metadata).length > 0 ? JSON.stringify(item.metadata, null, 2) : '',
});
};
const handleDeleteKnowledgeDocument = async (docId: string) => {
if (!selectedKnowledgeBaseId) return;
if (!window.confirm(t('confirmDeleteKnowledgeDocument'))) {
return;
}
setError('');
setSuccess('');
setDeletingKnowledgeDocumentId(docId);
try {
await api.delete(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/${docId}`);
if (editingKnowledgeDocumentId === docId) {
resetKnowledgeDocumentForm();
}
setSuccess(t('knowledgeDocumentDeleted'));
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentDeleteFailed'));
} finally {
setDeletingKnowledgeDocumentId('');
}
};
const handleUploadKnowledgeDocuments = async () => {
setError('');
setSuccess('');
if (!selectedKnowledgeBaseId) {
setError(t('selectKnowledgeBaseToManageDocuments'));
return;
}
if (knowledgeUploadFiles.length === 0) {
setError(t('knowledgeDocumentUploadEmpty'));
return;
}
setUploadingKnowledgeDocuments(true);
try {
const formData = new FormData();
knowledgeUploadFiles.forEach((file) => formData.append('files', file));
await api.post(`/api/v1/knowledge-bases/${selectedKnowledgeBaseId}/documents/upload`, formData);
setSuccess(t('knowledgeDocumentUploadSuccess', { count: knowledgeUploadFiles.length }));
setKnowledgeUploadFiles([]);
await fetchKnowledgeDocuments(selectedKnowledgeBaseId);
await fetchKnowledgeBases();
} catch (err: any) {
setError(err.message || t('knowledgeDocumentUploadFailed'));
} finally {
setUploadingKnowledgeDocuments(false);
}
};
const handleSave = async () => {
setError('');
setSuccess('');
@@ -589,8 +66,6 @@ export function Settings() {
}
};
const selectedKnowledgeBase = knowledgeBases.find((item) => item.id === selectedKnowledgeBaseId) || null;
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
@@ -668,401 +143,8 @@ export function Settings() {
</Button>
</CardFooter>
</Card>
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Database className="h-5 w-5 text-indigo-500" />
{t('knowledgeBaseSettings')}
</CardTitle>
<CardDescription>{t('knowledgeBaseSettingsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-lg border border-border p-4 space-y-4">
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">{t('knowledgeGlobalConfigTitle')}</div>
<div className="text-xs text-muted-foreground">{t('knowledgeGlobalConfigDesc')}</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-global-api-base">{t('knowledgeGlobalApiBase')}</Label>
<Input
id="knowledge-global-api-base"
value={knowledgeGlobalForm.api_base}
placeholder={t('knowledgeGlobalApiBasePlaceholder')}
onChange={(e) => setKnowledgeGlobalForm((prev) => ({ ...prev, api_base: e.target.value }))}
disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-global-api-key">{t('knowledgeGlobalApiKey')}</Label>
<Input
id="knowledge-global-api-key"
type="password"
value={knowledgeGlobalForm.api_key}
placeholder={t('knowledgeGlobalApiKeyPlaceholder')}
onChange={(e) => setKnowledgeGlobalForm((prev) => ({ ...prev, api_key: e.target.value }))}
disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig}
/>
<div className="text-xs text-muted-foreground">
{knowledgeGlobalConfig.has_api_key
? t('knowledgeGlobalApiKeyMasked', { masked: knowledgeGlobalConfig.api_key_masked || '******' })
: t('knowledgeGlobalApiKeyEmpty')}
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-global-default-embedding-model">{t('knowledgeGlobalDefaultEmbeddingModel')}</Label>
<Input
id="knowledge-global-default-embedding-model"
value={knowledgeGlobalForm.default_embedding_model}
placeholder={t('knowledgeGlobalDefaultEmbeddingModelPlaceholder')}
onChange={(e) => setKnowledgeGlobalForm((prev) => ({ ...prev, default_embedding_model: e.target.value }))}
disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig || isTestingKnowledgeGlobalConnection}
/>
<div className="text-xs text-muted-foreground">
{t('knowledgeGlobalModelNameHint')}
</div>
</div>
</div>
{knowledgeConnectionTestResult ? (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-700 space-y-1">
<div>{knowledgeConnectionTestResult.message}</div>
{knowledgeConnectionTestResult.model_name ? (
<div>{t('knowledgeGlobalConnectionModelResult', { model: knowledgeConnectionTestResult.model_name })}</div>
) : null}
{typeof knowledgeConnectionTestResult.embedding_dimension === 'number' ? (
<div>{t('knowledgeGlobalConnectionDimensionResult', { dim: knowledgeConnectionTestResult.embedding_dimension })}</div>
) : null}
{knowledgeConnectionTestResult.available_models && knowledgeConnectionTestResult.available_models.length > 0 ? (
<div>{t('knowledgeGlobalConnectionAvailableModelsResult', { models: knowledgeConnectionTestResult.available_models.slice(0, 5).join(', ') })}</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleTestKnowledgeGlobalConnection}
disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig || isTestingKnowledgeGlobalConnection}
>
{isTestingKnowledgeGlobalConnection ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Database className="h-4 w-4 mr-2" />}
{t('testKnowledgeGlobalConnection')}
</Button>
<Button
variant="outline"
onClick={() => void fetchKnowledgeGlobalConfig()}
disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig || isTestingKnowledgeGlobalConnection}
>
{isLoadingKnowledgeGlobalConfig ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
{t('refresh')}
</Button>
<Button onClick={handleSaveKnowledgeGlobalConfig} disabled={isLoadingKnowledgeGlobalConfig || isSavingKnowledgeGlobalConfig || isTestingKnowledgeGlobalConnection}>
{isSavingKnowledgeGlobalConfig ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('saveKnowledgeGlobalConfig')}
</Button>
</div>
</div>
{!currentProject ? (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-3">
{t('selectProjectBeforeManageKnowledgeBase')}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-name">{t('knowledgeBaseName')}</Label>
<Input
id="knowledge-base-name"
value={knowledgeBaseForm.name}
placeholder={t('knowledgeBaseNamePlaceholder')}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-description">{t('description')}</Label>
<Input
id="knowledge-base-description"
value={knowledgeBaseForm.description}
placeholder={t('knowledgeBaseDescriptionPlaceholder')}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, description: e.target.value }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-embedding-model">{t('knowledgeBaseEmbeddingModel')}</Label>
<Input
id="knowledge-base-embedding-model"
value={knowledgeBaseForm.embedding_model}
placeholder={t('knowledgeBaseEmbeddingModelPlaceholder')}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, embedding_model: e.target.value }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-size">{t('knowledgeBaseChunkSize')}</Label>
<Input
id="knowledge-base-chunk-size"
type="number"
value={knowledgeBaseForm.chunk_size}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, chunk_size: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-overlap">{t('knowledgeBaseChunkOverlap')}</Label>
<Input
id="knowledge-base-chunk-overlap"
type="number"
value={knowledgeBaseForm.chunk_overlap}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, chunk_overlap: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-top-k">{t('knowledgeBaseTopK')}</Label>
<Input
id="knowledge-base-top-k"
type="number"
value={knowledgeBaseForm.top_k}
onChange={(e) => setKnowledgeBaseForm((prev) => ({ ...prev, top_k: Number(e.target.value) || 0 }))}
disabled={!currentProject}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2 mt-7">
<Label htmlFor="knowledge-base-active">{t('activeStatus')}</Label>
<Switch
id="knowledge-base-active"
checked={knowledgeBaseForm.is_active}
onCheckedChange={(checked) => setKnowledgeBaseForm((prev) => ({ ...prev, is_active: checked }))}
disabled={!currentProject}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editingKnowledgeBaseId ? (
<Button variant="outline" onClick={resetKnowledgeBaseForm} disabled={isSavingKnowledgeBase}>
{t('cancel')}
</Button>
) : null}
<Button onClick={handleSaveKnowledgeBase} disabled={!currentProject || isSavingKnowledgeBase}>
{isSavingKnowledgeBase ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{editingKnowledgeBaseId ? t('updateKnowledgeBase') : t('createKnowledgeBase')}
</Button>
</div>
<div className="border-t border-border pt-4 space-y-3">
<div className="font-medium text-sm text-foreground/80">{t('knowledgeBaseList')}</div>
{isLoadingKnowledgeBases ? (
<div className="h-20 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : knowledgeBases.length === 0 ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('noKnowledgeBases')}
</div>
) : (
<div className="space-y-2">
{knowledgeBases.map((item) => (
<div key={item.id} className="rounded-lg border border-border p-3 flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="font-medium text-sm text-foreground truncate">{item.name}</div>
<div className="text-xs text-muted-foreground">
{t('knowledgeBaseMeta', {
count: item.documents?.length || 0,
updatedAt: new Date(item.updated_at).toLocaleString(),
})}
</div>
{item.description ? (
<div className="text-xs text-muted-foreground break-words">{item.description}</div>
) : null}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleOpenKnowledgeDocuments(item.id);
}}
title={t('manageKnowledgeDocuments')}
>
{selectedKnowledgeBaseId === item.id ? <Plus className="h-4 w-4 text-indigo-500" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={() => handleEditKnowledgeBase(item)} title={t('editKnowledgeBase')}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleReindexKnowledgeBase(item.id);
}}
disabled={reindexingKnowledgeBaseId === item.id}
title={t('reindexKnowledgeBase')}
>
{reindexingKnowledgeBaseId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleDeleteKnowledgeBase(item.id);
}}
disabled={deletingKnowledgeBaseId === item.id}
title={t('deleteKnowledgeBase')}
>
{deletingKnowledgeBaseId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4 text-red-500" />}
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="border-t border-border pt-4 space-y-3">
<div className="font-medium text-sm text-foreground/80">
{selectedKnowledgeBase
? t('knowledgeDocumentManagerTitle', { name: selectedKnowledgeBase.name })
: t('knowledgeDocumentManagerTitleEmpty')}
</div>
{!selectedKnowledgeBase ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('selectKnowledgeBaseToManageDocuments')}
</div>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="text-sm font-medium text-foreground">{t('knowledgeDocumentUploadTitle')}</div>
<Input
type="file"
multiple
onChange={(e) => setKnowledgeUploadFiles(Array.from(e.target.files || []))}
disabled={uploadingKnowledgeDocuments}
/>
<div className="text-xs text-muted-foreground">
{t('knowledgeDocumentUploadHint')}
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{knowledgeUploadFiles.length > 0
? t('knowledgeDocumentUploadSelected', { count: knowledgeUploadFiles.length })
: t('knowledgeDocumentUploadNone')}
</div>
<Button onClick={handleUploadKnowledgeDocuments} disabled={uploadingKnowledgeDocuments || knowledgeUploadFiles.length === 0}>
{uploadingKnowledgeDocuments ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
{t('knowledgeDocumentUploadAction')}
</Button>
</div>
</div>
<div className="grid gap-3">
<div className="space-y-2">
<Label htmlFor="knowledge-doc-title">{t('knowledgeDocumentTitle')}</Label>
<Input
id="knowledge-doc-title"
value={knowledgeDocumentForm.title}
placeholder={t('knowledgeDocumentTitlePlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, title: e.target.value }))}
disabled={isSavingKnowledgeDocument}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-doc-content">{t('knowledgeDocumentContent')}</Label>
<Textarea
id="knowledge-doc-content"
value={knowledgeDocumentForm.content}
placeholder={t('knowledgeDocumentContentPlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, content: e.target.value }))}
disabled={isSavingKnowledgeDocument}
rows={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-doc-metadata">{t('knowledgeDocumentMetadata')}</Label>
<Textarea
id="knowledge-doc-metadata"
value={knowledgeDocumentForm.metadata}
placeholder={t('knowledgeDocumentMetadataPlaceholder')}
onChange={(e) => setKnowledgeDocumentForm((prev) => ({ ...prev, metadata: e.target.value }))}
disabled={isSavingKnowledgeDocument}
rows={3}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editingKnowledgeDocumentId ? (
<Button variant="outline" onClick={resetKnowledgeDocumentForm} disabled={isSavingKnowledgeDocument}>
{t('cancel')}
</Button>
) : null}
<Button onClick={handleSaveKnowledgeDocument} disabled={isSavingKnowledgeDocument}>
{isSavingKnowledgeDocument ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{editingKnowledgeDocumentId ? t('updateKnowledgeDocument') : t('createKnowledgeDocument')}
</Button>
</div>
{isLoadingKnowledgeDocuments ? (
<div className="h-20 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : knowledgeDocuments.length === 0 ? (
<div className="text-sm text-muted-foreground rounded-md border border-dashed border-border p-4">
{t('noKnowledgeDocuments')}
</div>
) : (
<div className="space-y-2">
{knowledgeDocuments.map((doc) => (
<div key={doc.id} className="rounded-lg border border-border p-3 flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="font-medium text-sm text-foreground truncate">{doc.title}</div>
<div className="text-xs text-muted-foreground">
{t('knowledgeDocumentMeta', {
updatedAt: new Date(doc.updated_at).toLocaleString(),
})}
</div>
<div className="text-xs text-muted-foreground break-words">{doc.content.slice(0, 120)}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => handleEditKnowledgeDocument(doc)} title={t('editKnowledgeDocument')}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
void handleDeleteKnowledgeDocument(doc.id);
}}
disabled={deletingKnowledgeDocumentId === doc.id}
title={t('deleteKnowledgeDocument')}
>
{deletingKnowledgeDocumentId === doc.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4 text-red-500" />}
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</CardContent>
<CardFooter className="bg-muted/50/50 border-t border-border pt-6">
<Button variant="outline" onClick={() => void fetchKnowledgeBases()} disabled={!currentProject || isLoadingKnowledgeBases} className="ml-auto">
{isLoadingKnowledgeBases ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
{t('refreshKnowledgeBaseList')}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}
}