chore: layout optimize
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -124,10 +124,27 @@ class KnowledgeIndexService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_embed_model(kb: Dict[str, Any]) -> Any:
|
def _build_embed_model(kb: Dict[str, Any]) -> Any:
|
||||||
global_config = knowledge_global_config_store.get()
|
from app.services.embedding_model_store import embedding_model_store
|
||||||
api_base = global_config.get("api_base")
|
models = embedding_model_store.list_models()
|
||||||
api_key = global_config.get("api_key")
|
if not models:
|
||||||
model_name = kb.get("embedding_model") or global_config.get("default_embedding_model")
|
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:
|
if not api_base or not api_key or not model_name:
|
||||||
return None
|
return None
|
||||||
api_base = _normalize_embedding_api_base(api_base)
|
api_base = _normalize_embedding_api_base(api_base)
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ import re
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
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.postgres import postgres_connector
|
||||||
from app.connectors.clickhouse import clickhouse_connector
|
from app.connectors.clickhouse import clickhouse_connector
|
||||||
from app.core.artifacts import extract_artifacts
|
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(mcp.router, prefix="/api/v1")
|
||||||
app.include_router(subagents.router, prefix="/api/v1")
|
app.include_router(subagents.router, prefix="/api/v1")
|
||||||
app.include_router(knowledge.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
|
STREAM_DELTA_CHUNK_SIZE = 48
|
||||||
PREVIEWABLE_TEXT_EXTENSIONS = {
|
PREVIEWABLE_TEXT_EXTENSIONS = {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Users } from "./pages/Users";
|
|||||||
import { Projects } from "./pages/Projects";
|
import { Projects } from "./pages/Projects";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
import { ModelConfigs } from "./pages/ModelConfigs";
|
import { ModelConfigs } from "./pages/ModelConfigs";
|
||||||
|
import { EmbeddingModels } from "./pages/EmbeddingModels";
|
||||||
|
import { KnowledgeBases } from "./pages/KnowledgeBases";
|
||||||
import { DataSources } from "./pages/DataSources";
|
import { DataSources } from "./pages/DataSources";
|
||||||
import { Modeling } from "./pages/Modeling";
|
import { Modeling } from "./pages/Modeling";
|
||||||
import { Subagents } from "./pages/Subagents";
|
import { Subagents } from "./pages/Subagents";
|
||||||
@@ -127,6 +129,22 @@ function App() {
|
|||||||
</MainLayout>
|
</MainLayout>
|
||||||
</ProtectedRoute>
|
</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={
|
<Route path="/datasources" element={
|
||||||
<ProtectedRoute requireAdmin={true}>
|
<ProtectedRoute requireAdmin={true}>
|
||||||
|
|||||||
@@ -919,6 +919,17 @@ function SidebarBody() {
|
|||||||
{t('dataSourceManagement')}
|
{t('dataSourceManagement')}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -954,6 +965,17 @@ function SidebarBody() {
|
|||||||
{t('modelConfig')}
|
{t('modelConfig')}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,119 +4,17 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
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 { api } from "@/lib/api";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
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() {
|
export function Settings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, updateUser } = useAuthStore();
|
const { user, updateUser } = useAuthStore();
|
||||||
const { currentProject } = useProjectStore();
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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 [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
@@ -126,429 +24,8 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchKnowledgeBases();
|
|
||||||
}, [currentProject?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchKnowledgeGlobalConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
|
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 () => {
|
const handleSave = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
@@ -589,8 +66,6 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedKnowledgeBase = knowledgeBases.find((item) => item.id === selectedKnowledgeBaseId) || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
|
<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="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
|
||||||
@@ -668,401 +143,8 @@ export function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user