From bf83187d8cdcc9a5ba7b142ed0bbfe7a31d29d01 Mon Sep 17 00:00:00 2001 From: ShiYu Date: Sat, 18 Oct 2025 11:35:18 +0800 Subject: [PATCH] feat: add AI Settings tab for managing providers and models --- api/routers.py | 3 +- api/routes/ai_providers.py | 177 +++ api/routes/config.py | 26 +- models/database.py | 75 ++ schemas/ai.py | 101 ++ services/ai.py | 310 +++-- services/ai_providers.py | 347 +++++ services/processors/vector_index.py | 24 +- web/public/icon/claude-color.svg | 1 + web/public/icon/deepseek-color.svg | 1 + web/public/icon/gemini-color.svg | 1 + web/public/icon/openai.svg | 1 + web/public/icon/siliconcloud-color.svg | 1 + web/src/api/aiProviders.ts | 89 ++ web/src/i18n/locales/en.ts | 83 ++ web/src/i18n/locales/zh.ts | 83 ++ .../SystemSettingsPage/SystemSettingsPage.tsx | 581 +-------- .../components/AiSettingsTab.tsx | 1136 +++++++++++++++++ .../components/AppSettingsTab.tsx | 47 + .../components/AppearanceSettingsTab.tsx | 102 ++ .../components/VectorDbSettingsTab.tsx | 359 ++++++ web/src/router/LayoutShell.tsx | 20 +- web/src/styles/ai-settings.css | 361 ++++++ 23 files changed, 3280 insertions(+), 649 deletions(-) create mode 100644 api/routes/ai_providers.py create mode 100644 schemas/ai.py create mode 100644 services/ai_providers.py create mode 100644 web/public/icon/claude-color.svg create mode 100644 web/public/icon/deepseek-color.svg create mode 100644 web/public/icon/gemini-color.svg create mode 100644 web/public/icon/openai.svg create mode 100644 web/public/icon/siliconcloud-color.svg create mode 100644 web/src/api/aiProviders.ts create mode 100644 web/src/pages/SystemSettingsPage/components/AiSettingsTab.tsx create mode 100644 web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx create mode 100644 web/src/pages/SystemSettingsPage/components/AppearanceSettingsTab.tsx create mode 100644 web/src/pages/SystemSettingsPage/components/VectorDbSettingsTab.tsx create mode 100644 web/src/styles/ai-settings.css diff --git a/api/routers.py b/api/routers.py index 8128a1d..c3c5326 100644 --- a/api/routers.py +++ b/api/routers.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads +from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers from .routes import webdav from .routes import plugins @@ -18,6 +18,7 @@ def include_routers(app: FastAPI): app.include_router(share.public_router) app.include_router(backup.router) app.include_router(vector_db.router) + app.include_router(ai_providers.router) app.include_router(plugins.router) app.include_router(webdav.router) app.include_router(offline_downloads.router) diff --git a/api/routes/ai_providers.py b/api/routes/ai_providers.py new file mode 100644 index 0000000..3424e9a --- /dev/null +++ b/api/routes/ai_providers.py @@ -0,0 +1,177 @@ +from typing import Annotated, Dict, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Path + +from api.response import success +from schemas.ai import ( + AIDefaultsUpdate, + AIModelCreate, + AIModelUpdate, + AIProviderCreate, + AIProviderUpdate, +) +from services.ai_providers import AIProviderService +from services.auth import User, get_current_active_user +from services.vector_db import VectorDBService + + +router = APIRouter(prefix="/api/ai", tags=["ai"]) +service = AIProviderService() + + +@router.get("/providers") +async def list_providers( + current_user: Annotated[User, Depends(get_current_active_user)] +): + providers = await service.list_providers() + return success({"providers": providers}) + + +@router.post("/providers") +async def create_provider( + payload: AIProviderCreate, + current_user: Annotated[User, Depends(get_current_active_user)] +): + provider = await service.create_provider(payload.dict()) + return success(provider) + + +@router.get("/providers/{provider_id}") +async def get_provider( + provider_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + provider = await service.get_provider(provider_id, with_models=True) + return success(provider) + + +@router.put("/providers/{provider_id}") +async def update_provider( + provider_id: Annotated[int, Path(..., gt=0)], + payload: AIProviderUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], +): + data = {k: v for k, v in payload.dict().items() if v is not None} + if not data: + raise HTTPException(status_code=400, detail="No fields to update") + provider = await service.update_provider(provider_id, data) + return success(provider) + + +@router.delete("/providers/{provider_id}") +async def delete_provider( + provider_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + await service.delete_provider(provider_id) + return success({"id": provider_id}) + + +@router.post("/providers/{provider_id}/sync-models") +async def sync_models( + provider_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + try: + result = await service.sync_models(provider_id) + except (httpx.RequestError, httpx.HTTPStatusError) as exc: + raise HTTPException(status_code=502, detail=f"Failed to synchronize models: {exc}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return success(result) + + +@router.get("/providers/{provider_id}/remote-models") +async def fetch_remote_models( + provider_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + try: + models = await service.fetch_remote_models(provider_id) + except (httpx.RequestError, httpx.HTTPStatusError) as exc: + raise HTTPException(status_code=502, detail=f"Failed to pull models: {exc}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return success({"models": models}) + + +@router.get("/providers/{provider_id}/models") +async def list_models( + provider_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + models = await service.list_models(provider_id) + return success({"models": models}) + + +@router.post("/providers/{provider_id}/models") +async def create_model( + provider_id: Annotated[int, Path(..., gt=0)], + payload: AIModelCreate, + current_user: Annotated[User, Depends(get_current_active_user)], +): + model = await service.create_model(provider_id, payload.dict()) + return success(model) + + +@router.put("/models/{model_id}") +async def update_model( + model_id: Annotated[int, Path(..., gt=0)], + payload: AIModelUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], +): + data = {k: v for k, v in payload.dict().items() if v is not None} + if not data: + raise HTTPException(status_code=400, detail="No fields to update") + model = await service.update_model(model_id, data) + return success(model) + + +@router.delete("/models/{model_id}") +async def delete_model( + model_id: Annotated[int, Path(..., gt=0)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + await service.delete_model(model_id) + return success({"id": model_id}) + + +def _get_embedding_dimension(entry: Optional[Dict]) -> Optional[int]: + if not entry: + return None + value = entry.get("embedding_dimensions") + return int(value) if value is not None else None + + +@router.get("/defaults") +async def get_defaults( + current_user: Annotated[User, Depends(get_current_active_user)], +): + defaults = await service.get_default_models() + return success(defaults) + + +@router.put("/defaults") +async def update_defaults( + payload: AIDefaultsUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], +): + previous = await service.get_default_models() + try: + updated = await service.set_default_models(payload.as_mapping()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + prev_dim = _get_embedding_dimension(previous.get("embedding")) + next_dim = _get_embedding_dimension(updated.get("embedding")) + + if prev_dim and next_dim and prev_dim != next_dim: + try: + await VectorDBService().clear_all_data() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"Failed to clear vector database: {exc}") from exc + + return success(updated) diff --git a/api/routes/config.py b/api/routes/config.py index b8650da..2f11718 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -1,11 +1,10 @@ import httpx import time -from fastapi import APIRouter, Depends, Form, HTTPException +from fastapi import APIRouter, Depends, Form from typing import Annotated from services.config import ConfigCenter, VERSION from services.auth import get_current_active_user, User, has_users from api.response import success -from services.vector_db import VectorDBService router = APIRouter(prefix="/api/config", tags=["config"]) @@ -24,27 +23,8 @@ async def set_config( key: str = Form(...), value: str = Form(...) ): - original_value = await ConfigCenter.get(key) - value_to_save = value - if key == "AI_EMBED_DIM": - try: - parsed_value = int(value) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="AI_EMBED_DIM must be an integer") - if parsed_value <= 0: - raise HTTPException(status_code=400, detail="AI_EMBED_DIM must be greater than zero") - value_to_save = str(parsed_value) - - await ConfigCenter.set(key, value_to_save) - - if key == "AI_EMBED_DIM" and str(original_value) != value_to_save: - try: - service = VectorDBService() - await service.clear_all_data() - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to clear vector database: {exc}") - - return success({"key": key, "value": value_to_save}) + await ConfigCenter.set(key, value) + return success({"key": key, "value": value}) @router.get("/all") diff --git a/models/database.py b/models/database.py index fde3f88..3bcf7de 100644 --- a/models/database.py +++ b/models/database.py @@ -36,6 +36,81 @@ class Configuration(Model): table = "configurations" +class AIProvider(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=100) + identifier = fields.CharField(max_length=100, unique=True) + provider_type = fields.CharField(max_length=50, null=True) + api_format = fields.CharField(max_length=20) + base_url = fields.CharField(max_length=512, null=True) + api_key = fields.CharField(max_length=512, null=True) + logo_url = fields.CharField(max_length=512, null=True) + extra_config = fields.JSONField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "ai_providers" + + +class AIModel(Model): + id = fields.IntField(pk=True) + provider: fields.ForeignKeyRelation[AIProvider] = fields.ForeignKeyField( + "models.AIProvider", related_name="models", on_delete=fields.CASCADE + ) + name = fields.CharField(max_length=255) + display_name = fields.CharField(max_length=255, null=True) + description = fields.TextField(null=True) + capabilities = fields.JSONField(null=True) + context_window = fields.IntField(null=True) + metadata = fields.JSONField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "ai_models" + unique_together = ("provider", "name") + + @property + def embedding_dimensions(self) -> int | None: + metadata = self.metadata or {} + if not isinstance(metadata, dict): + return None + value = metadata.get("embedding_dimensions") + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @embedding_dimensions.setter + def embedding_dimensions(self, value: int | None) -> None: + base_metadata = self.metadata if isinstance(self.metadata, dict) else {} + metadata = dict(base_metadata or {}) + if value is None: + metadata.pop("embedding_dimensions", None) + else: + try: + metadata["embedding_dimensions"] = int(value) + except (TypeError, ValueError): + metadata.pop("embedding_dimensions", None) + self.metadata = metadata or None + + +class AIDefaultModel(Model): + id = fields.IntField(pk=True) + ability = fields.CharField(max_length=50, unique=True) + model: fields.ForeignKeyRelation[AIModel] = fields.ForeignKeyField( + "models.AIModel", related_name="default_for", on_delete=fields.CASCADE + ) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "ai_default_models" + + class AutomationTask(Model): id = fields.IntField(pk=True) name = fields.CharField(max_length=100) diff --git a/schemas/ai.py b/schemas/ai.py new file mode 100644 index 0000000..68d4ae9 --- /dev/null +++ b/schemas/ai.py @@ -0,0 +1,101 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + +from services.ai_providers import ABILITIES, normalize_capabilities + + +class AIProviderBase(BaseModel): + name: str + identifier: str = Field(..., pattern=r"^[a-z0-9_\-\.]+$") + provider_type: Optional[str] = None + api_format: str + base_url: Optional[str] = None + api_key: Optional[str] = None + logo_url: Optional[str] = None + extra_config: Optional[dict] = None + + @field_validator("api_format") + def normalize_format(cls, value: str) -> str: + fmt = value.lower() + if fmt not in {"openai", "gemini"}: + raise ValueError("api_format must be 'openai' or 'gemini'") + return fmt + + +class AIProviderCreate(AIProviderBase): + pass + + +class AIProviderUpdate(BaseModel): + name: Optional[str] = None + provider_type: Optional[str] = None + api_format: Optional[str] = None + base_url: Optional[str] = None + api_key: Optional[str] = None + logo_url: Optional[str] = None + extra_config: Optional[dict] = None + + @field_validator("api_format") + def normalize_format(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + fmt = value.lower() + if fmt not in {"openai", "gemini"}: + raise ValueError("api_format must be 'openai' or 'gemini'") + return fmt + + +class AIModelBase(BaseModel): + name: str + display_name: Optional[str] = None + description: Optional[str] = None + capabilities: Optional[List[str]] = None + context_window: Optional[int] = None + embedding_dimensions: Optional[int] = None + metadata: Optional[dict] = None + + @field_validator("capabilities") + def validate_capabilities(cls, items: Optional[List[str]]) -> Optional[List[str]]: + if items is None: + return None + normalized = normalize_capabilities(items) + invalid = set(items) - set(normalized) + if invalid: + raise ValueError(f"Unsupported capabilities: {', '.join(invalid)}") + return normalized + + +class AIModelCreate(AIModelBase): + pass + + +class AIModelUpdate(BaseModel): + display_name: Optional[str] = None + description: Optional[str] = None + capabilities: Optional[List[str]] = None + context_window: Optional[int] = None + embedding_dimensions: Optional[int] = None + metadata: Optional[dict] = None + + @field_validator("capabilities") + def validate_capabilities(cls, items: Optional[List[str]]) -> Optional[List[str]]: + if items is None: + return None + normalized = normalize_capabilities(items) + invalid = set(items) - set(normalized) + if invalid: + raise ValueError(f"Unsupported capabilities: {', '.join(invalid)}") + return normalized + + +class AIDefaultsUpdate(BaseModel): + chat: Optional[int] = None + vision: Optional[int] = None + embedding: Optional[int] = None + rerank: Optional[int] = None + voice: Optional[int] = None + tools: Optional[int] = None + + def as_mapping(self) -> dict: + return {ability: getattr(self, ability) for ability in ABILITIES} diff --git a/services/ai.py b/services/ai.py index 803031b..286ae3c 100644 --- a/services/ai.py +++ b/services/ai.py @@ -1,113 +1,247 @@ +from __future__ import annotations + import httpx -from typing import List -from services.config import ConfigCenter +from typing import List, Sequence, Tuple + +from models.database import AIModel, AIProvider +from services.ai_providers import AIProviderService + + +provider_service = AIProviderService() + + +class MissingModelError(RuntimeError): + pass async def describe_image_base64(base64_image: str, detail: str = "high") -> str: """ - 传入base64图片和文本提示,返回图片描述文本。 + 传入 base64 图片并返回描述文本。缺省时返回错误提示。 """ - OAI_API_URL = await ConfigCenter.get("AI_VISION_API_URL") - VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL") - API_KEY = await ConfigCenter.get("AI_VISION_API_KEY") - payload = { - "model": VISION_MODEL, - "messages": [ - {"role": "user", "content": [ - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - "detail": detail - } - }, - { - "type": "text", - "text": "描述这个图片" - } - ]} - ] - } - headers = { - "Authorization": f"Bearer {API_KEY}", - "Content-Type": "application/json" - } try: - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.post(OAI_API_URL, headers=headers, json=payload) - resp.raise_for_status() - result = resp.json() - return result["choices"][0]["message"]["content"] + model, provider = await _require_model("vision") + if provider.api_format == "openai": + return await _describe_with_openai(provider, model, base64_image, detail) + return await _describe_with_gemini(provider, model, base64_image, detail) + except MissingModelError as exc: + return str(exc) except httpx.ReadTimeout: return "请求超时,请稍后重试。" - except Exception as e: - return f"请求失败: {str(e)}" + except Exception as exc: # noqa: BLE001 + return f"请求失败: {exc}" async def get_text_embedding(text: str) -> List[float]: """ - 传入文本,返回嵌入向量。 + 传入文本,返回嵌入向量。若未配置模型则抛出异常。 """ - OAI_API_URL = await ConfigCenter.get("AI_EMBED_API_URL") - EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL") - API_KEY = await ConfigCenter.get("AI_EMBED_API_KEY") - payload = { - "model": EMBED_MODEL, - "input": text - } - headers = { - "Authorization": f"Bearer {API_KEY}", - "Content-Type": "application/json" - } - async with httpx.AsyncClient() as client: - if OAI_API_URL.endswith("chat/completions"): - url = OAI_API_URL.replace("chat/completions", "embeddings") - else: - url = OAI_API_URL - resp = await client.post(url, headers=headers, json=payload) - resp.raise_for_status() - result = resp.json() - return result["data"][0]["embedding"] + model, provider = await _require_model("embedding") + if provider.api_format == "openai": + return await _embedding_with_openai(provider, model, text) + return await _embedding_with_gemini(provider, model, text) -async def rerank_texts(query: str, documents: List[str]) -> List[float]: +async def rerank_texts(query: str, documents: Sequence[str]) -> List[float]: """调用重排序模型,为一组文档返回得分。未配置时返回空列表。""" if not documents: return [] - - api_url = await ConfigCenter.get("AI_RERANK_API_URL") - model = await ConfigCenter.get("AI_RERANK_MODEL") - api_key = await ConfigCenter.get("AI_RERANK_API_KEY") - - if not api_url or not model or not api_key: + try: + model, provider = await _require_model("rerank") + except MissingModelError: return [] + try: + if provider.api_format == "openai": + return await _rerank_with_openai(provider, model, query, documents) + return await _rerank_with_gemini(provider, model, query, documents) + except Exception: # noqa: BLE001 + return [] + + +async def _require_model(ability: str) -> Tuple[AIModel, AIProvider]: + model = await provider_service.get_default_model(ability) + if not model: + raise MissingModelError(f"未配置默认 {ability} 模型,请前往系统设置完成配置。") + provider = getattr(model, "provider", None) + if provider is None: + await model.fetch_related("provider") + provider = model.provider + if provider is None: + raise MissingModelError("模型缺少关联的提供商配置。") + if not provider.base_url: + raise MissingModelError("该提供商未设置 API 地址。") + return model, provider + + +def _openai_endpoint(provider: AIProvider, path: str) -> str: + base = (provider.base_url or "").rstrip("/") + if not base: + raise MissingModelError("提供商 API 地址未配置。") + return f"{base}/{path.lstrip('/')}" + + +def _openai_headers(provider: AIProvider) -> dict: + headers = {"Content-Type": "application/json"} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + return headers + + +def _gemini_endpoint(provider: AIProvider, path: str) -> str: + base = (provider.base_url or "").rstrip("/") + if not base: + raise MissingModelError("提供商 API 地址未配置。") + url = f"{base}/{path.lstrip('/')}" + if provider.api_key: + connector = "&" if "?" in url else "?" + url = f"{url}{connector}key={provider.api_key}" + return url + + +async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str: + url = _openai_endpoint(provider, "/chat/completions") payload = { - "model": model, - "query": query, - "documents": documents, - } - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", + "model": model.name, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}", + "detail": detail, + }, + }, + {"type": "text", "text": "描述这个图片"}, + ], + } + ], } + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, headers=_openai_headers(provider), json=payload) + response.raise_for_status() + body = response.json() + return body["choices"][0]["message"]["content"] - async with httpx.AsyncClient() as client: + +async def _describe_with_gemini(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str: + detail_text = f"描述这个图片,细节等级:{detail}" + model_name = model.name if model.name.startswith("models/") else f"models/{model.name}" + url = _gemini_endpoint(provider, f"{model_name}:generateContent") + payload = { + "contents": [ + { + "role": "user", + "parts": [ + { + "inline_data": { + "mime_type": "image/jpeg", + "data": base64_image, + } + }, + {"text": detail_text}, + ], + } + ] + } + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + body = response.json() + candidates = body.get("candidates") or [] + if not candidates: + return "" + parts = candidates[0].get("content", {}).get("parts", []) + text_parts = [part.get("text") for part in parts if isinstance(part, dict) and part.get("text")] + return "\n".join(text_parts) + + +async def _embedding_with_openai(provider: AIProvider, model: AIModel, text: str) -> List[float]: + url = _openai_endpoint(provider, "/embeddings") + payload = { + "model": model.name, + "input": text, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=_openai_headers(provider), json=payload) + response.raise_for_status() + body = response.json() + return body["data"][0]["embedding"] + + +async def _embedding_with_gemini(provider: AIProvider, model: AIModel, text: str) -> List[float]: + model_name = model.name if model.name.startswith("models/") else f"models/{model.name}" + url = _gemini_endpoint(provider, f"{model_name}:embedContent") + payload = { + "model": model_name, + "content": { + "parts": [{"text": text}], + }, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + body = response.json() + embedding = body.get("embedding") or {} + return embedding.get("values") or [] + + +async def _rerank_with_openai( + provider: AIProvider, + model: AIModel, + query: str, + documents: Sequence[str], +) -> List[float]: + url = _openai_endpoint(provider, "/rerank") + payload = { + "model": model.name, + "query": query, + "documents": [ + {"id": str(idx), "text": content} + for idx, content in enumerate(documents) + ], + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=_openai_headers(provider), json=payload) + response.raise_for_status() + body = response.json() + results = body.get("results") or body.get("data") or [] + scores: List[float] = [] + for item in results: + try: + scores.append(float(item.get("score", 0.0))) + except (TypeError, ValueError): + scores.append(0.0) + return scores + + +async def _rerank_with_gemini( + provider: AIProvider, + model: AIModel, + query: str, + documents: Sequence[str], +) -> List[float]: + model_name = model.name if model.name.startswith("models/") else f"models/{model.name}" + url = _gemini_endpoint(provider, f"{model_name}:rankContent") + payload = { + "query": {"text": query}, + "documents": [ + {"id": str(idx), "content": {"parts": [{"text": content}]}} + for idx, content in enumerate(documents) + ], + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + body = response.json() + + scores: List[float] = [] + ranked = body.get("rankedDocuments") or body.get("results") or [] + for item in ranked: + raw_score = item.get("relevanceScore") or item.get("score") or item.get("confidenceScore") try: - resp = await client.post(api_url, headers=headers, json=payload) - resp.raise_for_status() - except httpx.HTTPStatusError: - return [] - data = resp.json() - if isinstance(data, dict): - results = data.get("results") - if isinstance(results, list): - scores = [] - for item in results: - if isinstance(item, dict) and "score" in item: - try: - scores.append(float(item["score"])) - except (TypeError, ValueError): - scores.append(0.0) - return scores - return [] + scores.append(float(raw_score)) + except (TypeError, ValueError): + scores.append(0.0) + return scores diff --git a/services/ai_providers.py b/services/ai_providers.py new file mode 100644 index 0000000..242c9df --- /dev/null +++ b/services/ai_providers.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from tortoise.exceptions import DoesNotExist +from tortoise.transactions import in_transaction + +from models.database import AIDefaultModel, AIModel, AIProvider + + +ABILITIES = ["chat", "vision", "embedding", "rerank", "voice", "tools"] + +OPENAI_EMBEDDING_DIMS = { + "text-embedding-3-large": 3072, + "text-embedding-3-small": 1536, + "text-embedding-ada-002": 1536, +} + + +def _normalize_embedding_dim(value: Any) -> Optional[int]: + if value is None: + return None + try: + casted = int(value) + except (TypeError, ValueError): + return None + return casted if casted > 0 else None + + +def _apply_embedding_dim_to_metadata( + data: Dict[str, Any], + embedding_dim: Optional[int], + base_metadata: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + source = base_metadata if isinstance(base_metadata, dict) else {} + metadata: Dict[str, Any] = dict(source) + override = data.get("metadata") + if isinstance(override, dict) and override: + metadata.update(override) + if embedding_dim is None: + metadata.pop("embedding_dimensions", None) + else: + metadata["embedding_dimensions"] = embedding_dim + data["metadata"] = metadata or None + return data + + +def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]: + if not items: + return [] + normalized = [] + for cap in items: + key = str(cap).strip().lower() + if key in ABILITIES and key not in normalized: + normalized.append(key) + return normalized + + +def infer_openai_capabilities(model_id: str) -> Tuple[List[str], Optional[int]]: + lower = model_id.lower() + caps = set() + + if any(keyword in lower for keyword in ["gpt", "chat", "turbo", "o1", "sonnet", "haiku", "thinking"]): + caps.update({"chat", "tools"}) + + if any(keyword in lower for keyword in ["vision", "gpt-4o", "gpt-4.1", "o1", "vision-preview", "omni"]): + caps.add("vision") + + if any(keyword in lower for keyword in ["embed", "embedding"]): + caps.add("embedding") + + if "rerank" in lower or "re-rank" in lower: + caps.add("rerank") + + if any(keyword in lower for keyword in ["tts", "speech", "audio"]): + caps.add("voice") + + embedding_dim = OPENAI_EMBEDDING_DIMS.get(model_id) + return normalize_capabilities(caps), embedding_dim + + +def infer_gemini_capabilities(methods: Iterable[str]) -> List[str]: + caps = set() + for method in methods: + m = method.lower() + if m in {"generatecontent", "counttokens"}: + caps.update({"chat", "tools", "vision"}) + if m == "embedcontent": + caps.add("embedding") + if m in {"generatespeech", "audiogeneration"}: + caps.add("voice") + if m == "rerank": + caps.add("rerank") + return normalize_capabilities(caps) + + +def serialize_provider(provider: AIProvider) -> Dict[str, Any]: + return { + "id": provider.id, + "name": provider.name, + "identifier": provider.identifier, + "provider_type": provider.provider_type, + "api_format": provider.api_format, + "base_url": provider.base_url, + "api_key": provider.api_key, + "logo_url": provider.logo_url, + "extra_config": provider.extra_config or {}, + "created_at": provider.created_at, + "updated_at": provider.updated_at, + } + + +def model_to_dict(model: AIModel, provider: Optional[AIProvider] = None) -> Dict[str, Any]: + provider_obj = provider or getattr(model, "provider", None) + provider_data = serialize_provider(provider_obj) if provider_obj else None + return { + "id": model.id, + "provider_id": model.provider_id, + "name": model.name, + "display_name": model.display_name, + "description": model.description, + "capabilities": normalize_capabilities(model.capabilities), + "context_window": model.context_window, + "embedding_dimensions": model.embedding_dimensions, + "metadata": model.metadata or {}, + "created_at": model.created_at, + "updated_at": model.updated_at, + "provider": provider_data, + } + + +def provider_to_dict(provider: AIProvider, models: Optional[List[AIModel]] = None) -> Dict[str, Any]: + data = serialize_provider(provider) + if models is not None: + data["models"] = [model_to_dict(m, provider=provider) for m in models] + return data + + +class AIProviderService: + async def list_providers(self) -> List[Dict[str, Any]]: + providers = await AIProvider.all().order_by("id").prefetch_related("models") + return [provider_to_dict(p, models=list(p.models)) for p in providers] + + async def get_provider(self, provider_id: int, with_models: bool = False) -> Dict[str, Any]: + if with_models: + provider = await AIProvider.get(id=provider_id) + models = await provider.models.all() + return provider_to_dict(provider, models=models) + else: + provider = await AIProvider.get(id=provider_id) + return provider_to_dict(provider) + + async def create_provider(self, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload.copy() + data.setdefault("extra_config", {}) + provider = await AIProvider.create(**data) + return provider_to_dict(provider) + + async def update_provider(self, provider_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + provider = await AIProvider.get(id=provider_id) + for field, value in payload.items(): + setattr(provider, field, value) + await provider.save() + return provider_to_dict(provider) + + async def delete_provider(self, provider_id: int) -> None: + await AIProvider.filter(id=provider_id).delete() + + async def list_models(self, provider_id: int) -> List[Dict[str, Any]]: + models = await AIModel.filter(provider_id=provider_id).order_by("id").prefetch_related("provider") + return [model_to_dict(m) for m in models] + + async def create_model(self, provider_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload.copy() + data["provider_id"] = provider_id + data["capabilities"] = normalize_capabilities(data.get("capabilities")) + embedding_dim = _normalize_embedding_dim(data.pop("embedding_dimensions", None)) + data = _apply_embedding_dim_to_metadata(data, embedding_dim) + model = await AIModel.create(**data) + await model.fetch_related("provider") + return model_to_dict(model) + + async def update_model(self, model_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + model = await AIModel.get(id=model_id) + data = payload.copy() + if "capabilities" in data: + data["capabilities"] = normalize_capabilities(data.get("capabilities")) + embedding_dim = None + if "embedding_dimensions" in data: + embedding_dim = _normalize_embedding_dim(data.pop("embedding_dimensions", None)) + _apply_embedding_dim_to_metadata(data, embedding_dim, base_metadata=model.metadata) + for field, value in data.items(): + setattr(model, field, value) + if embedding_dim is not None or ("embedding_dimensions" in payload and embedding_dim is None): + model.embedding_dimensions = embedding_dim + await model.save() + await model.fetch_related("provider") + return model_to_dict(model) + + async def delete_model(self, model_id: int) -> None: + await AIModel.filter(id=model_id).delete() + + async def fetch_remote_models(self, provider_id: int) -> List[Dict[str, Any]]: + provider = await AIProvider.get(id=provider_id) + return await self._get_remote_models(provider) + + async def _get_remote_models(self, provider: AIProvider) -> List[Dict[str, Any]]: + if not provider.base_url: + raise ValueError("Provider base_url is required for syncing models") + + fmt = (provider.api_format or "").lower() + if fmt not in {"openai", "gemini"}: + raise ValueError(f"Unsupported api_format '{provider.api_format}' for syncing models") + + if fmt == "openai": + return await self._fetch_openai_models(provider) + return await self._fetch_gemini_models(provider) + + async def sync_models(self, provider_id: int) -> Dict[str, int]: + provider = await AIProvider.get(id=provider_id) + remote_models = await self._get_remote_models(provider) + + created = 0 + updated = 0 + for entry in remote_models: + defaults = entry.copy() + model_id = defaults.pop("name") + defaults["capabilities"] = normalize_capabilities(defaults.get("capabilities")) + embedding_dim = _normalize_embedding_dim(defaults.pop("embedding_dimensions", None)) + defaults = _apply_embedding_dim_to_metadata(defaults, embedding_dim) + obj, is_created = await AIModel.get_or_create( + provider_id=provider.id, + name=model_id, + defaults=defaults, + ) + if is_created: + created += 1 + continue + for field, value in defaults.items(): + setattr(obj, field, value) + if embedding_dim is not None or ("embedding_dimensions" in entry and embedding_dim is None): + obj.embedding_dimensions = embedding_dim + await obj.save() + updated += 1 + + return {"created": created, "updated": updated} + + async def get_default_models(self) -> Dict[str, Optional[Dict[str, Any]]]: + defaults = await AIDefaultModel.all().prefetch_related("model__provider") + result: Dict[str, Optional[Dict[str, Any]]] = {ability: None for ability in ABILITIES} + for item in defaults: + result[item.ability] = model_to_dict(item.model, provider=item.model.provider) # type: ignore[attr-defined] + return result + + async def set_default_models(self, mapping: Dict[str, Optional[int]]) -> Dict[str, Optional[Dict[str, Any]]]: + normalized = {ability: mapping.get(ability) for ability in ABILITIES} + async with in_transaction() as connection: + for ability, model_id in normalized.items(): + record = await AIDefaultModel.get_or_none(ability=ability) + if model_id: + try: + model = await AIModel.get(id=model_id) + except DoesNotExist: + raise ValueError(f"Model {model_id} not found") + if record: + record.model_id = model_id + await record.save(using_db=connection) + else: + await AIDefaultModel.create(ability=ability, model_id=model_id) + elif record: + await record.delete(using_db=connection) + return await self.get_default_models() + + async def get_default_model(self, ability: str) -> Optional[AIModel]: + ability_key = ability.lower() + if ability_key not in ABILITIES: + return None + record = await AIDefaultModel.get_or_none(ability=ability_key) + if not record: + return None + model = await AIModel.get_or_none(id=record.model_id) + if model: + await model.fetch_related("provider") + return model + + async def _fetch_openai_models(self, provider: AIProvider) -> List[Dict[str, Any]]: + base_url = provider.base_url.rstrip("/") + url = f"{base_url}/models" + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + + data = payload.get("data", []) + entries: List[Dict[str, Any]] = [] + for item in data: + model_id = item.get("id") + if not model_id: + continue + capabilities, embedding_dim = infer_openai_capabilities(model_id) + entries.append({ + "name": model_id, + "display_name": item.get("display_name"), + "description": item.get("description"), + "capabilities": capabilities, + "context_window": item.get("context_window"), + "embedding_dimensions": embedding_dim, + "metadata": item, + }) + return entries + + async def _fetch_gemini_models(self, provider: AIProvider) -> List[Dict[str, Any]]: + base_url = provider.base_url.rstrip("/") + suffix = "/models" + if provider.api_key: + suffix += f"?key={provider.api_key}" + url = f"{base_url}{suffix}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + response.raise_for_status() + payload = response.json() + + data = payload.get("models", []) + entries: List[Dict[str, Any]] = [] + for item in data: + model_id = item.get("name") + if not model_id: + continue + methods = item.get("supportedGenerationMethods") or [] + capabilities = infer_gemini_capabilities(methods) + entries.append({ + "name": model_id, + "display_name": item.get("displayName"), + "description": item.get("description"), + "capabilities": capabilities, + "context_window": item.get("inputTokenLimit"), + "embedding_dimensions": item.get("embeddingDimensions"), + "metadata": item, + }) + return entries diff --git a/services/processors/vector_index.py b/services/processors/vector_index.py index 8335774..5d2b616 100644 --- a/services/processors/vector_index.py +++ b/services/processors/vector_index.py @@ -5,15 +5,11 @@ import mimetypes import os from io import BytesIO -from services.ai import describe_image_base64, get_text_embedding +from services.ai import describe_image_base64, get_text_embedding, provider_service from services.vector_db import VectorDBService, DEFAULT_VECTOR_DIMENSION from services.logging import LogService -from services.config import ConfigCenter +from PIL import Image -try: # Pillow is optional but bundled with the project dependencies - from PIL import Image -except ImportError: # pragma: no cover - fallback when pillow missing - Image = None CHUNK_SIZE = 800 @@ -150,13 +146,15 @@ class VectorIndexProcessor: file_ext = path.split('.')[-1].lower() details: Dict[str, Any] = {"path": path, "action": "create", "index_type": "vector"} - raw_dim = await ConfigCenter.get('AI_EMBED_DIM', DEFAULT_VECTOR_DIMENSION) - try: - vector_dim = int(raw_dim) - except (TypeError, ValueError): - vector_dim = DEFAULT_VECTOR_DIMENSION - if vector_dim <= 0: - vector_dim = DEFAULT_VECTOR_DIMENSION + embedding_model = await provider_service.get_default_model("embedding") + vector_dim = DEFAULT_VECTOR_DIMENSION + if embedding_model and getattr(embedding_model, "embedding_dimensions", None): + try: + vector_dim = int(embedding_model.embedding_dimensions) + except (TypeError, ValueError): + vector_dim = DEFAULT_VECTOR_DIMENSION + if vector_dim <= 0: + vector_dim = DEFAULT_VECTOR_DIMENSION await vector_db.ensure_collection(collection_name, vector=True, dim=vector_dim) await vector_db.delete_vector(collection_name, path) diff --git a/web/public/icon/claude-color.svg b/web/public/icon/claude-color.svg new file mode 100644 index 0000000..62dc0db --- /dev/null +++ b/web/public/icon/claude-color.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/web/public/icon/deepseek-color.svg b/web/public/icon/deepseek-color.svg new file mode 100644 index 0000000..3fc2302 --- /dev/null +++ b/web/public/icon/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/web/public/icon/gemini-color.svg b/web/public/icon/gemini-color.svg new file mode 100644 index 0000000..f1cf357 --- /dev/null +++ b/web/public/icon/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/web/public/icon/openai.svg b/web/public/icon/openai.svg new file mode 100644 index 0000000..50d94d6 --- /dev/null +++ b/web/public/icon/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/web/public/icon/siliconcloud-color.svg b/web/public/icon/siliconcloud-color.svg new file mode 100644 index 0000000..6b5f6d8 --- /dev/null +++ b/web/public/icon/siliconcloud-color.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/web/src/api/aiProviders.ts b/web/src/api/aiProviders.ts new file mode 100644 index 0000000..c7cbce8 --- /dev/null +++ b/web/src/api/aiProviders.ts @@ -0,0 +1,89 @@ +import request from './client'; + +export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools'; + +export interface AIProviderPayload { + name: string; + identifier: string; + provider_type?: string | null; + api_format: 'openai' | 'gemini'; + base_url?: string | null; + api_key?: string | null; + logo_url?: string | null; + extra_config?: Record | null; +} + +export interface AIProvider extends Omit { + id: number; + extra_config: Record; + created_at: string; + updated_at: string; + models?: AIModel[]; +} + +export interface AIModelPayload { + name: string; + display_name?: string | null; + description?: string | null; + capabilities?: AIAbility[]; + context_window?: number | null; + embedding_dimensions?: number | null; + metadata?: Record | null; +} + +export interface AIModel extends Omit { + id: number; + provider_id: number; + metadata: Record; + created_at: string; + updated_at: string; + provider?: AIProvider; +} + +export type AIDefaultAssignments = Partial>; +export type AIDefaultModels = Partial>; + +export async function fetchProviders() { + const data = await request<{ providers: AIProvider[] }>('/ai/providers'); + return data.providers; +} + +export async function createProvider(payload: AIProviderPayload) { + return request('/ai/providers', { method: 'POST', json: payload }); +} + +export async function updateProvider(id: number, payload: Partial) { + return request(`/ai/providers/${id}`, { method: 'PUT', json: payload }); +} + +export async function deleteProvider(id: number) { + await request(`/ai/providers/${id}`, { method: 'DELETE' }); +} + +export async function syncProviderModels(id: number) { + return request<{ created: number; updated: number }>(`/ai/providers/${id}/sync-models`, { method: 'POST' }); +} + +export async function fetchRemoteModels(providerId: number) { + return request<{ models: AIModelPayload[] }>(`/ai/providers/${providerId}/remote-models`); +} + +export async function createModel(providerId: number, payload: AIModelPayload) { + return request(`/ai/providers/${providerId}/models`, { method: 'POST', json: payload }); +} + +export async function updateModel(modelId: number, payload: Partial) { + return request(`/ai/models/${modelId}`, { method: 'PUT', json: payload }); +} + +export async function deleteModel(modelId: number) { + await request(`/ai/models/${modelId}`, { method: 'DELETE' }); +} + +export async function fetchDefaults() { + return request('/ai/defaults'); +} + +export async function updateDefaults(payload: AIDefaultAssignments) { + return request('/ai/defaults', { method: 'PUT', json: payload }); +} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index c4b9103..817be1e 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -307,6 +307,89 @@ export const en = { 'Vision API Key': 'Vision API Key', 'Embedding API URL': 'Embedding API URL', 'Embedding API Key': 'Embedding API Key', + 'AI Providers & Models': 'AI Providers & Models', + 'Manage AI providers, synchronize compatible models, and configure default capabilities across the system.': 'Manage AI providers, synchronize compatible models, and configure default capabilities across the system.', + 'Add Provider': 'Add Provider', + 'Edit Provider': 'Edit Provider', + 'Pull Models': 'Pull Models', + 'Manual Add': 'Manual Add', + 'Clear Remote List': 'Clear Remote List', + 'Select models from the list to add them automatically': 'Select models from the list to add them automatically', + 'No remote models': 'No remote models', + 'No remote models found': 'No remote models found', + 'No remote models match search': 'No remote models match search', + 'Search fetched models': 'Search fetched models', + 'Already Added': 'Already Added', + 'Add Selected Models': 'Add Selected Models', + 'Fetch failed': 'Fetch failed', + 'Select models to add': 'Select models to add', + 'Added {count} models': 'Added {count} models', + 'Choose Template': 'Choose Template', + 'Configure Provider': 'Configure Provider', + 'Back to Templates': 'Back to Templates', + 'View Docs': 'View Docs', + 'Custom Provider': 'Custom Provider', + 'Custom Provider Description': 'Bring your own endpoint compatible with OpenAI or Gemini formats.', + 'OpenAI Provider': 'OpenAI', + 'OpenAI Provider Description': 'Access GPT-4o, GPT-4.1, GPT-3.5 and more models from OpenAI.', + 'Azure OpenAI Provider': 'Azure OpenAI', + 'Azure OpenAI Provider Description': 'Use OpenAI models deployed on Microsoft Azure.', + 'Google AI Provider': 'Google AI', + 'Google AI Provider Description': 'Gemini series models served via the Google AI platform.', + 'SiliconFlow Provider': 'SiliconFlow', + 'SiliconFlow Provider Description': 'High-performance inference platform with OpenAI-compatible APIs.', + 'OpenRouter Provider': 'OpenRouter', + 'OpenRouter Provider Description': 'Connect to multiple AI providers through a single OpenAI-style endpoint.', + 'Anthropic Provider': 'Anthropic', + 'Anthropic Provider Description': 'Claude 3 family models exposed through the Claude API.', + 'DeepSeek Provider': 'DeepSeek', + 'DeepSeek Provider Description': 'DeepSeek language models via OpenAI-compatible API.', + 'Grok Provider': 'Grok (xAI)', + 'Grok Provider Description': 'Grok models powered by xAI with OpenAI-style routes.', + 'Ollama Provider': 'Ollama', + 'Ollama Provider Description': 'Self-host and run models locally with Ollama\'s OpenAI bridge.', + 'Voyage Provider': 'Voyage AI', + 'Voyage Provider Description': 'High-quality embeddings and rerankers from Voyage AI.', + 'Delete provider?': 'Delete provider?', + 'Deleting this provider will also remove all associated models. Continue?': 'Deleting this provider will also remove all associated models. Continue?', + 'Deleted successfully': 'Deleted successfully', + 'Sync Models': 'Sync Models', + 'Sync completed: {created} created, {updated} updated': 'Sync completed: {created} created, {updated} updated', + 'Sync failed': 'Sync failed', + 'Add Model': 'Add Model', + 'Edit Model': 'Edit Model', + 'Delete model?': 'Delete model?', + 'This operation cannot be undone. Continue?': 'This operation cannot be undone. Continue?', + 'No models yet': 'No models yet', + 'Add your first AI provider to get started': 'Add your first AI provider to get started', + 'Default Models Configuration': 'Default Models Configuration', + 'Main Chat Model': 'Main Chat Model', + 'Primary assistant for conversations, reasoning, and tool calls.': 'Primary assistant for conversations, reasoning, and tool calls.', + 'Handles multimodal perception such as image understanding.': 'Handles multimodal perception such as image understanding.', + 'Transforms content into dense vectors for search and retrieval.': 'Transforms content into dense vectors for search and retrieval.', + 'Optimises ranking quality for search candidates.': 'Optimises ranking quality for search candidates.', + 'Covers text-to-speech and speech understanding scenarios.': 'Covers text-to-speech and speech understanding scenarios.', + 'Supports function calling, orchestration, and automation.': 'Supports function calling, orchestration, and automation.', + 'Select a model': 'Select a model', + 'Template': 'Template', + 'Select a template': 'Select a template', + 'Display Name': 'Display Name', + 'Enter name': 'Enter name', + 'Identifier': 'Identifier', + 'Enter identifier': 'Enter identifier', + 'Only lowercase letters, numbers, dash, dot and underscore are allowed': 'Only lowercase letters, numbers, dash, dot and underscore are allowed', + 'API Format': 'API Format', + 'Base URL': 'Base URL', + 'Enter base url': 'Enter base URL', + 'Optional, can also be provided per request': 'Optional, can also be provided per request', + 'Model Identifier': 'Model Identifier', + 'Enter model identifier': 'Enter model identifier', + 'Description': 'Description', + 'Capabilities': 'Capabilities', + 'Context Window': 'Context Window', + 'Embedding Dimensions': 'Embedding Dimensions', + 'Price /1K input tokens': 'Price /1K input tokens', + 'Price /1K output tokens': 'Price /1K output tokens', // Adapters 'Missing required config:': 'Missing required config:', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 422499d..6132a84 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -259,6 +259,10 @@ export const zh = { 'Save': '保存', 'App Settings': '应用设置', 'AI Settings': 'AI设置', + 'Choose Template': '选择模板', + 'Configure Provider': '配置提供商', + 'Back to Templates': '返回选择', + 'View Docs': '查看文档', 'Vision Model': '视觉模型', 'Embedding Model': '嵌入模型', 'Embedding Dimension': '向量维度', @@ -308,6 +312,85 @@ export const zh = { 'Vision API Key': '视觉模型 API Key', 'Embedding API URL': '嵌入模型 API 地址', 'Embedding API Key': '嵌入模型 API Key', + 'AI Providers & Models': 'AI 提供商与模型', + 'Manage AI providers, synchronize compatible models, and configure default capabilities across the system.': '管理所有 AI 提供商,批量同步兼容模型,并配置系统默认能力。', + 'Add Provider': '添加提供商', + 'Edit Provider': '编辑提供商', + 'Pull Models': '拉取模型', + 'Manual Add': '手动添加', + 'Clear Remote List': '清空列表', + 'Select models from the list to add them automatically': '选择模型后可一键添加到系统', + 'No remote models': '暂无远程模型', + 'No remote models found': '未获取到远程模型', + 'No remote models match search': '没有匹配的远程模型', + 'Search fetched models': '搜索已拉取模型', + 'Already Added': '已添加', + 'Add Selected Models': '添加所选模型', + 'Fetch failed': '拉取失败', + 'Select models to add': '请选择要添加的模型', + 'Added {count} models': '已添加 {count} 个模型', + 'Custom Provider': '自定义提供商', + 'Custom Provider Description': '自定义兼容 OpenAI 或 Gemini 标准的 API 端点。', + 'OpenAI Provider': 'OpenAI', + 'OpenAI Provider Description': '访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-3.5 等模型。', + 'Azure OpenAI Provider': 'Azure OpenAI', + 'Azure OpenAI Provider Description': '使用托管在微软 Azure 上的 OpenAI 模型。', + 'Google AI Provider': 'Google AI', + 'Google AI Provider Description': 'Google AI 平台提供的 Gemini 系列模型。', + 'SiliconFlow Provider': '硅基流动', + 'SiliconFlow Provider Description': '硅基流动高性能推理平台,兼容 OpenAI 接口。', + 'OpenRouter Provider': 'OpenRouter', + 'OpenRouter Provider Description': '通过一个 OpenAI 风格入口接入多家 AI 提供商。', + 'Anthropic Provider': 'Anthropic', + 'Anthropic Provider Description': '通过 Claude API 使用 Claude 3 系列模型。', + 'DeepSeek Provider': 'DeepSeek', + 'DeepSeek Provider Description': 'DeepSeek 语言模型,支持 OpenAI 兼容接口。', + 'Grok Provider': 'Grok (xAI)', + 'Grok Provider Description': 'xAI 的 Grok 模型,提供 OpenAI 风格接口。', + 'Ollama Provider': 'Ollama', + 'Ollama Provider Description': '使用 Ollama 在本地运行并管理大模型。', + 'Voyage Provider': 'Voyage AI', + 'Voyage Provider Description': 'Voyage AI 提供的高质量嵌入与重排序模型。', + 'Delete provider?': '确认删除该提供商?', + 'Deleting this provider will also remove all associated models. Continue?': '删除后将同时移除该提供商下的全部模型,是否继续?', + 'Deleted successfully': '删除成功', + 'Sync Models': '同步模型', + 'Sync completed: {created} created, {updated} updated': '同步完成:新增 {created} 个,更新 {updated} 个', + 'Sync failed': '同步失败', + 'Add Model': '添加模型', + 'Edit Model': '编辑模型', + 'Delete model?': '确认删除该模型?', + 'This operation cannot be undone. Continue?': '此操作不可撤销,是否继续?', + 'No models yet': '暂无模型', + 'Add your first AI provider to get started': '添加第一个 AI 提供商开始配置', + 'Default Models Configuration': '默认模型配置', + 'Main Chat Model': '主对话模型', + 'Primary assistant for conversations, reasoning, and tool calls.': '用于对话、推理与工具调用的核心模型。', + 'Handles multimodal perception such as image understanding.': '负责多模态感知与图像理解。', + 'Transforms content into dense vectors for search and retrieval.': '将内容向量化以驱动搜索与检索。', + 'Optimises ranking quality for search candidates.': '重新排序候选结果,提升检索相关性。', + 'Covers text-to-speech and speech understanding scenarios.': '覆盖文本转语音与语音理解场景。', + 'Supports function calling, orchestration, and automation.': '支持函数调用、编排与自动化。', + 'Select a model': '选择模型', + 'Template': '模板', + 'Select a template': '选择模板', + 'Display Name': '显示名称', + 'Enter name': '请输入名称', + 'Identifier': '标识符', + 'Enter identifier': '请输入标识符', + 'Only lowercase letters, numbers, dash, dot and underscore are allowed': '仅允许小写字母、数字、连字符、点和下划线', + 'API Format': 'API 格式', + 'Base URL': '基础 URL', + 'Enter base url': '请输入基础 URL', + 'Optional, can also be provided per request': '可选,也可在请求时提供', + 'Model Identifier': '模型标识', + 'Enter model identifier': '请输入模型标识', + 'Description': '描述', + 'Capabilities': '能力标签', + 'Context Window': '上下文窗口', + 'Embedding Dimensions': '向量维度', + 'Price /1K input tokens': '价格 /1K 输入 token', + 'Price /1K output tokens': '价格 /1K 输出 token', // Adapters 'Missing required config:': '缺少必填配置:', diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index 7339bc7..2122f22 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -1,12 +1,27 @@ -import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal, Radio, InputNumber, Spin, Empty, Alert } from 'antd'; -import { useEffect, useState, useCallback } from 'react'; +import { message, Tabs, Space } from 'antd'; +import { useEffect, useState } from 'react'; import PageCard from '../../components/PageCard'; import { getAllConfig, setConfig } from '../../api/config'; -import { vectorDBApi, type VectorDBStats, type VectorDBProviderMeta, type VectorDBCurrentConfig } from '../../api/vectorDB'; import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons'; import { useTheme } from '../../contexts/ThemeContext'; import '../../styles/settings-tabs.css'; import { useI18n } from '../../i18n'; +import AppearanceSettingsTab from './components/AppearanceSettingsTab'; +import AppSettingsTab from './components/AppSettingsTab'; +import AiSettingsTab from './components/AiSettingsTab'; +import VectorDbSettingsTab from './components/VectorDbSettingsTab'; + +type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db'; + +const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db']; +const DEFAULT_TAB: TabKey = 'appearance'; + +const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key); + +interface SystemSettingsPageProps { + tabKey?: string; + onTabNavigate?: (key: TabKey, options?: { replace?: boolean }) => void; +} const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [ { key: 'APP_NAME', label: 'App Name' }, @@ -15,57 +30,6 @@ const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [ { key: 'FILE_DOMAIN', label: 'File Domain' }, ]; -interface AiConfigKeyBase { - key: string; - default?: string | number; -} - -interface AiConfigKeyWithLabel extends AiConfigKeyBase { - label: string; -} - -const VISION_CONFIG_KEYS: AiConfigKeyWithLabel[] = [ - { key: 'AI_VISION_API_URL', label: 'Vision API URL' }, - { key: 'AI_VISION_MODEL', label: 'Vision Model', default: 'Qwen/Qwen2.5-VL-32B-Instruct' }, - { key: 'AI_VISION_API_KEY', label: 'Vision API Key' }, -]; - -const DEFAULT_EMBED_DIMENSION = 4096; -const EMBED_DIM_KEY = 'AI_EMBED_DIM'; - -const EMBED_CONFIG_KEYS: AiConfigKeyWithLabel[] = [ - { key: 'AI_EMBED_API_URL', label: 'Embedding API URL' }, - { key: 'AI_EMBED_MODEL', label: 'Embedding Model', default: 'Qwen/Qwen3-Embedding-8B' }, - { key: 'AI_EMBED_API_KEY', label: 'Embedding API Key' }, -]; - -const RERANK_CONFIG_KEYS: AiConfigKeyWithLabel[] = [ - { key: 'AI_RERANK_API_URL', label: 'Rerank API URL' }, - { key: 'AI_RERANK_MODEL', label: 'Rerank Model' }, - { key: 'AI_RERANK_API_KEY', label: 'Rerank API Key' }, -]; - -const ALL_AI_KEYS: AiConfigKeyBase[] = [ - ...VISION_CONFIG_KEYS, - ...EMBED_CONFIG_KEYS, - ...RERANK_CONFIG_KEYS, - { key: EMBED_DIM_KEY, default: DEFAULT_EMBED_DIMENSION }, -]; - -const formatBytes = (bytes?: number | null) => { - if (bytes === null || bytes === undefined) return '-'; - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let value = bytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - const precision = value >= 10 || unitIndex === 0 ? 0 : 1; - return `${value.toFixed(precision)} ${units[unitIndex]}`; -}; - // Theme related config keys const THEME_KEYS = { MODE: 'THEME_MODE', @@ -75,101 +39,30 @@ const THEME_KEYS = { CSS: 'THEME_CUSTOM_CSS', }; -export default function SystemSettingsPage() { - const [vectorConfigForm] = Form.useForm(); +export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) { const [loading, setLoading] = useState(false); const [config, setConfigState] = useState | null>(null); - const [activeTab, setActiveTab] = useState('appearance'); - const [vectorStats, setVectorStats] = useState(null); - const [vectorStatsLoading, setVectorStatsLoading] = useState(false); - const [vectorStatsError, setVectorStatsError] = useState(null); - const [vectorProviders, setVectorProviders] = useState([]); - const [vectorConfig, setVectorConfig] = useState(null); - const [vectorConfigLoading, setVectorConfigLoading] = useState(false); - const [vectorConfigSaving, setVectorConfigSaving] = useState(false); - const [vectorMetaError, setVectorMetaError] = useState(null); - const [selectedProviderType, setSelectedProviderType] = useState(null); - const { refreshTheme, previewTheme } = useTheme(); + const [activeTab, setActiveTab] = useState(() => + isValidTab(tabKey) ? tabKey : DEFAULT_TAB + ); + const { refreshTheme } = useTheme(); const { t } = useI18n(); useEffect(() => { getAllConfig().then((data) => setConfigState(data as Record)); }, []); - const fetchVectorStats = useCallback(async () => { - setVectorStatsLoading(true); - setVectorStatsError(null); - try { - const data = await vectorDBApi.getStats(); - setVectorStats(data); - } catch (e: any) { - const msg = e?.message || t('Load failed'); - setVectorStatsError(msg); - message.error(msg); - } finally { - setVectorStatsLoading(false); - } - }, [t]); - - const buildProviderConfigValues = useCallback((provider: VectorDBProviderMeta | undefined, existing?: Record) => { - if (!provider) return {}; - const values: Record = {}; - const schema = provider.config_schema || []; - schema.forEach((field) => { - const current = existing && existing[field.key] !== undefined && existing[field.key] !== null - ? String(existing[field.key]) - : undefined; - if (current !== undefined) { - values[field.key] = current; - } else if (field.default !== undefined && field.default !== null) { - values[field.key] = String(field.default); - } else { - values[field.key] = ''; - } - }); - return values; - }, []); - - const fetchVectorMeta = useCallback(async () => { - setVectorConfigLoading(true); - setVectorMetaError(null); - try { - const [providers, current] = await Promise.all([ - vectorDBApi.getProviders(), - vectorDBApi.getConfig(), - ]); - setVectorProviders(providers); - setVectorConfig(current); - - const enabled = providers.filter((item) => item.enabled); - let nextType: string | null = current?.type ?? null; - if (nextType && !providers.some((item) => item.type === nextType)) { - nextType = null; - } - if (!nextType) { - nextType = enabled[0]?.type ?? providers[0]?.type ?? null; - } - setSelectedProviderType(nextType); - const provider = providers.find((item) => item.type === nextType); - const configValues = buildProviderConfigValues(provider, nextType === current?.type ? current?.config : undefined); - vectorConfigForm.setFieldsValue({ type: nextType || undefined, config: configValues }); - } catch (e: any) { - const msg = e?.message || t('Load failed'); - setVectorMetaError(msg); - message.error(msg); - } finally { - setVectorConfigLoading(false); - } - }, [buildProviderConfigValues, message, t, vectorConfigForm]); - - const handleSave = async (values: any) => { + const handleSave = async (values: Record) => { setLoading(true); try { for (const [key, value] of Object.entries(values)) { await setConfig(key, String(value ?? '')); } message.success(t('Saved successfully')); - setConfigState({ ...config, ...values }); + const stringValues = Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, String(value ?? '')]), + ) as Record; + setConfigState((prev) => ({ ...(prev ?? {}), ...stringValues })); // trigger theme refresh if related keys changed if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) { await refreshTheme(); @@ -180,69 +73,31 @@ export default function SystemSettingsPage() { setLoading(false); }; - const handleProviderChange = useCallback((value: string) => { - setSelectedProviderType(value); - const provider = vectorProviders.find((item) => item.type === value); - const existing = value === vectorConfig?.type ? vectorConfig?.config : undefined; - const configValues = buildProviderConfigValues(provider, existing); - vectorConfigForm.setFieldsValue({ type: value, config: configValues }); - }, [vectorProviders, vectorConfig, buildProviderConfigValues, vectorConfigForm]); - - const handleVectorConfigSave = useCallback(async (values: { type: string; config?: Record }) => { - if (!values?.type) { + // 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览) + useEffect(() => { + if (!isValidTab(tabKey)) { + setActiveTab((prev) => (prev === DEFAULT_TAB ? prev : DEFAULT_TAB)); + if (tabKey !== DEFAULT_TAB) { + onTabNavigate?.(DEFAULT_TAB, { replace: true }); + } return; } - setVectorConfigSaving(true); - try { - const configPayload = Object.fromEntries( - Object.entries(values.config || {}).filter(([, val]) => val !== undefined && val !== null && String(val).trim() !== '') - .map(([key, val]) => [key, String(val)]) - ); - const response = await vectorDBApi.updateConfig({ type: values.type, config: configPayload }); - setVectorConfig(response.config); - setVectorStats(response.stats); - setVectorStatsError(null); - setSelectedProviderType(response.config.type); - const provider = vectorProviders.find((item) => item.type === response.config.type); - const mergedValues = buildProviderConfigValues(provider, response.config.config); - vectorConfigForm.setFieldsValue({ type: response.config.type, config: mergedValues }); - message.success(t('Saved successfully')); - } catch (e: any) { - message.error(e?.message || t('Save failed')); - } finally { - setVectorConfigSaving(false); - } - }, [buildProviderConfigValues, message, t, vectorConfigForm, vectorProviders]); + setActiveTab((prev) => (prev === tabKey ? prev : tabKey)); + }, [tabKey, onTabNavigate]); - const vectorSectionLoading = vectorStatsLoading || vectorConfigLoading; - - // 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览) useEffect(() => { if (activeTab !== 'appearance') { refreshTheme(); } - }, [activeTab]); + }, [activeTab, refreshTheme]); - useEffect(() => { - if (activeTab === 'vector-db') { - if (!vectorProviders.length && !vectorConfigLoading) { - fetchVectorMeta(); - } - if (!vectorStats && !vectorStatsLoading) { - fetchVectorStats(); - } + const handleTabChange = (key: string) => { + const nextKey: TabKey = isValidTab(key) ? key : DEFAULT_TAB; + if (nextKey !== activeTab) { + setActiveTab(nextKey); } - }, [ - activeTab, - fetchVectorMeta, - fetchVectorStats, - vectorProviders.length, - vectorConfigLoading, - vectorStats, - vectorStatsLoading, - ]); - - const selectedProvider = vectorProviders.find((item) => item.type === selectedProviderType || (!selectedProviderType && item.enabled)); + onTabNavigate?.(nextKey); + }; if (!config) { return
{t('Loading...')}
; @@ -256,7 +111,7 @@ export default function SystemSettingsPage() { ), children: ( -
{ - try { - const tokens = all[THEME_KEYS.TOKENS] ? JSON.parse(all[THEME_KEYS.TOKENS]) : undefined; - previewTheme({ - mode: all[THEME_KEYS.MODE], - primaryColor: all[THEME_KEYS.PRIMARY], - borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined, - customTokens: tokens, - customCSS: all[THEME_KEYS.CSS], - }); - } catch { - // JSON 不合法时忽略 tokens 预览,其他项仍然生效 - previewTheme({ - mode: all[THEME_KEYS.MODE], - primaryColor: all[THEME_KEYS.PRIMARY], - borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined, - customCSS: all[THEME_KEYS.CSS], - }); - } - }} - onFinish={async (vals) => { - // Validate JSON if provided - if (vals[THEME_KEYS.TOKENS]) { - try { JSON.parse(vals[THEME_KEYS.TOKENS]); } - catch { return message.error(t('Advanced tokens must be valid JSON')); } - } - await handleSave(vals); - }} - style={{ marginTop: 24 }} - key={'appearance-' + JSON.stringify(config)} - > - - - - {t('Light')} - {t('Dark')} - {t('Follow System')} - - - - - - - - - - - - - - - - - - - - -
+ ) }, { @@ -349,26 +141,12 @@ export default function SystemSettingsPage() { ), children: ( -
[key, config[key] ?? def ?? ''])), - }} - onFinish={handleSave} - style={{ marginTop: 24 }} - key={JSON.stringify(config)} - > - {APP_CONFIG_KEYS.map(({ key, label }) => ( - - - - ))} - - - -
+ ), }, { @@ -380,63 +158,8 @@ export default function SystemSettingsPage() { ), children: ( -
[key, key === EMBED_DIM_KEY - ? Number(config[key] ?? def ?? DEFAULT_EMBED_DIMENSION) - : config[key] ?? def ?? ''])), - }} - onFinish={async (vals) => { - const currentDim = Number(config[EMBED_DIM_KEY] ?? DEFAULT_EMBED_DIMENSION); - const nextDim = Number(vals[EMBED_DIM_KEY] ?? DEFAULT_EMBED_DIMENSION); - if (currentDim !== nextDim) { - Modal.confirm({ - title: t('Confirm embedding dimension change'), - content: t('Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?'), - okText: t('Confirm'), - cancelText: t('Cancel'), - onOk: async () => { - await handleSave(vals); - }, - }); - return; - } - await handleSave(vals); - }} - style={{ marginTop: 24 }} - key={JSON.stringify(config)} - > - - {VISION_CONFIG_KEYS.map(({ key, label }) => ( - - - - ))} - - - {EMBED_CONFIG_KEYS.map(({ key, label }) => ( - - - - ))} - - - - - - {RERANK_CONFIG_KEYS.map(({ key, label }) => ( - - - - ))} - - - - -
+ ), }, { @@ -448,191 +171,7 @@ export default function SystemSettingsPage() { ), children: ( - - - -
- {t('Current Statistics')} - -
- {vectorSectionLoading ? ( -
- -
- ) : ( - <> - {vectorMetaError ? ( - - ) : null} - {vectorStats ? ( - -
-
-
{t('Collections')}
-
{vectorStats.collection_count}
-
-
-
{t('Vectors')}
-
{vectorStats.total_vectors}
-
-
-
{t('Database Size')}
-
{formatBytes(vectorStats.db_file_size_bytes)}
-
-
-
{t('Estimated Memory')}
-
{formatBytes(vectorStats.estimated_total_memory_bytes)}
-
-
- {vectorStats.collections.length ? ( - - {vectorStats.collections.map((collection) => ( -
- -
- {collection.name} - - {collection.is_vector_collection && collection.dimension - ? `${t('Dimension')}: ${collection.dimension}` - : t('Non-vector collection')} - -
-
{t('Vectors')}: {collection.row_count}
- {collection.is_vector_collection ? ( -
{t('Estimated memory')}: {formatBytes(collection.estimated_memory_bytes)}
- ) : null} - {collection.indexes.length ? ( - - {t('Indexes')}: -
    - {collection.indexes.map((index) => ( -
  • - {index.index_name || t('Unnamed index')} - {' · '}{index.index_type || '-'} - {' · '}{index.metric_type || '-'} - {' · '}{t('Indexed rows')}: {index.indexed_rows} - {' · '}{t('Pending rows')}: {index.pending_index_rows} - {' · '}{t('Status')}: {index.state || '-'} -
  • - ))} -
-
- ) : null} -
-
- ))} -
- ) : ( - - )} -
- {t('Estimated memory is calculated as vectors x dimension x 4 bytes (float32).')} -
-
- ) : vectorStatsError ? ( -
{vectorStatsError}
- ) : ( - - )} -
- - - )} - - ))} - {selectedProvider && !selectedProvider.enabled ? ( - - ) : null} - - - - - - - - - )} -
-
-
+ ), }, ]} diff --git a/web/src/pages/SystemSettingsPage/components/AiSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/AiSettingsTab.tsx new file mode 100644 index 0000000..135e97e --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/AiSettingsTab.tsx @@ -0,0 +1,1136 @@ +import { + Alert, + Button, + Card, + Col, + Checkbox, + Drawer, + Empty, + Form, + Input, + InputNumber, + List, + Modal, + Row, + Select, + Space, + Tag, + Tooltip, + Typography, + Steps, + Tabs, + message, +} from 'antd'; +import { + ArrowLeftOutlined, + ArrowRightOutlined, + AppstoreOutlined, + BuildOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, + EyeOutlined, + MessageOutlined, + PlusOutlined, + ReloadOutlined, + RobotOutlined, + SoundOutlined, + SortAscendingOutlined, + ToolOutlined, +} from '@ant-design/icons'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { + AIDefaultAssignments, + AIDefaultModels, + AIAbility, + AIModel, + AIModelPayload, + AIProvider, + AIProviderPayload, +} from '../../../api/aiProviders'; +import { + createModel, + createProvider, + deleteModel, + deleteProvider, + fetchDefaults, + fetchProviders, + fetchRemoteModels, + updateDefaults, + updateModel, + updateProvider, +} from '../../../api/aiProviders'; +import { useI18n } from '../../../i18n'; +import '../../../styles/ai-settings.css'; + +type ProviderModalState = { + open: boolean; + step: 1 | 2; + editing?: AIProvider | null; +}; + +type ModelModalState = { + open: boolean; + provider?: AIProvider | null; + editing?: AIModel | null; +}; + +interface ProviderTemplate { + key: string; + nameKey: string; + descriptionKey: string; + api_format: 'openai' | 'gemini'; + identifier: string; + base_url?: string; + logo_url?: string; + provider_type?: string; + doc_url?: string; + allow_format_switch?: boolean; +} + +const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools']; + +const abilityInfo: Record = { + chat: { + icon: , + label: 'Main Chat Model', + color: 'purple', + description: 'Primary assistant for conversations, reasoning, and tool calls.', + }, + vision: { + icon: , + label: 'Vision Model', + color: 'geekblue', + description: 'Handles multimodal perception such as image understanding.', + }, + embedding: { + icon: , + label: 'Embedding Model', + color: 'gold', + description: 'Transforms content into dense vectors for search and retrieval.', + }, + rerank: { + icon: , + label: 'Rerank Model', + color: 'cyan', + description: 'Optimises ranking quality for search candidates.', + }, + voice: { + icon: , + label: 'Voice Model', + color: 'orange', + description: 'Covers text-to-speech and speech understanding scenarios.', + }, + tools: { + icon: , + label: 'Tools Model', + color: 'magenta', + description: 'Supports function calling, orchestration, and automation.', + }, +}; + +const providerTemplates: ProviderTemplate[] = [ + { + key: 'custom-provider', + nameKey: 'Custom Provider', + descriptionKey: 'Custom Provider Description', + api_format: 'openai', + identifier: 'custom-provider', + allow_format_switch: true, + }, + { + key: 'openai', + nameKey: 'OpenAI Provider', + descriptionKey: 'OpenAI Provider Description', + api_format: 'openai', + identifier: 'openai', + base_url: 'https://api.openai.com/v1', + logo_url: '/icon/openai.svg', + provider_type: 'builtin', + doc_url: 'https://platform.openai.com/docs/api-reference', + }, + { + key: 'google-ai', + nameKey: 'Google AI Provider', + descriptionKey: 'Google AI Provider Description', + api_format: 'gemini', + identifier: 'google-ai', + base_url: 'https://generativelanguage.googleapis.com/v1beta', + logo_url: '/icon/gemini-color.svg', + provider_type: 'builtin', + doc_url: 'https://ai.google.dev/api/rest', + }, + { + key: 'siliconflow', + nameKey: 'SiliconFlow Provider', + descriptionKey: 'SiliconFlow Provider Description', + api_format: 'openai', + identifier: 'siliconflow', + base_url: 'https://api.siliconflow.cn/v1', + logo_url: '/icon/siliconcloud-color.svg', + provider_type: 'builtin', + doc_url: 'https://docs.siliconflow.cn/', + }, + { + key: 'deepseek', + nameKey: 'DeepSeek Provider', + descriptionKey: 'DeepSeek Provider Description', + api_format: 'openai', + identifier: 'deepseek', + base_url: 'https://api.deepseek.com/v1', + logo_url: '/icon/deepseek-color.svg', + provider_type: 'builtin', + doc_url: 'https://platform.deepseek.com/api-docs', + }, +]; + +const abilityTagColor: Record = { + chat: 'purple', + vision: 'geekblue', + embedding: 'gold', + rerank: 'cyan', + voice: 'orange', + tools: 'magenta', +}; + +type AIProviderFormValues = { + name?: string; + identifier?: string; + api_format: AIProviderPayload['api_format']; + base_url?: string; + api_key?: string; + logo_url?: string; + provider_type?: string; +}; + +type AIModelFormValues = Omit; + +interface RemoteModelCandidate extends AIModelPayload { + exists: boolean; +} + +const { Title, Text } = Typography; + +export default function AiSettingsTab() { + const { t } = useI18n(); + const [providers, setProviders] = useState([]); + const [defaults, setDefaults] = useState({}); + const [defaultSelections, setDefaultSelections] = useState({}); + const [loading, setLoading] = useState(true); + const [savingDefaults, setSavingDefaults] = useState(false); + const [providerModal, setProviderModal] = useState({ open: false, step: 1 }); + const [modelModal, setModelModal] = useState({ open: false }); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [providerForm] = Form.useForm(); + const [modelForm] = Form.useForm(); + const [modelMetadata, setModelMetadata] = useState | null>(null); + const [remoteModels, setRemoteModels] = useState([]); + const [selectedRemoteModels, setSelectedRemoteModels] = useState([]); + const [pullingRemoteModels, setPullingRemoteModels] = useState(false); + const [addingRemoteModels, setAddingRemoteModels] = useState(false); + const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote'); + const [remoteSearchKeyword, setRemoteSearchKeyword] = useState(''); + const capabilitiesValue = Form.useWatch('capabilities', modelForm); + const showEmbeddingDimensions = useMemo(() => { + const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : []; + return capabilities.includes('embedding') || capabilities.includes('rerank'); + }, [capabilitiesValue]); + + useEffect(() => { + if (!showEmbeddingDimensions) { + modelForm.setFieldsValue({ embedding_dimensions: null }); + } + }, [modelForm, showEmbeddingDimensions]); + + const filteredRemoteModels = useMemo(() => { + const keyword = remoteSearchKeyword.trim().toLowerCase(); + if (!keyword) { + return remoteModels; + } + return remoteModels.filter((item) => { + const name = (item.name || '').toLowerCase(); + const displayName = (item.display_name || '').toLowerCase(); + return name.includes(keyword) || displayName.includes(keyword); + }); + }, [remoteModels, remoteSearchKeyword]); + + const refreshData = useCallback(async () => { + setLoading(true); + try { + const [providerList, defaultMap] = await Promise.all([ + fetchProviders(), + fetchDefaults(), + ]); + setProviders(providerList.map((item) => ({ + ...item, + models: (item.models || []).sort((a, b) => a.name.localeCompare(b.name)), + }))); + setDefaults(defaultMap); + const initialSelections: AIDefaultAssignments = {}; + abilityOrder.forEach((ability) => { + const model = defaultMap[ability]; + initialSelections[ability] = model ? model.id : null; + }); + setDefaultSelections(initialSelections); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Load failed')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void refreshData(); + }, [refreshData]); + + const handleOpenProviderModal = (existing?: AIProvider | null) => { + if (existing) { + setProviderModal({ open: true, editing: existing, step: 2 }); + const matchedTemplate = providerTemplates.find((item) => item.identifier === existing.identifier) ?? null; + setSelectedTemplate(matchedTemplate); + providerForm.setFieldsValue({ + name: existing.name, + identifier: existing.identifier, + api_format: existing.api_format, + base_url: existing.base_url ?? undefined, + api_key: existing.api_key ?? undefined, + logo_url: existing.logo_url ?? undefined, + provider_type: existing.provider_type ?? undefined, + }); + } else { + providerForm.resetFields(); + providerForm.setFieldsValue({ api_format: 'openai' }); + setSelectedTemplate(null); + setProviderModal({ open: true, step: 1 }); + } + }; + + const handleCloseProviderModal = () => { + setProviderModal({ open: false, step: 1 }); + setSelectedTemplate(null); + providerForm.resetFields(); + }; + + const handleTemplateSelect = (template: ProviderTemplate) => { + setSelectedTemplate(template); + setProviderModal((prev) => ({ ...prev, step: 2 })); + providerForm.setFieldsValue({ + name: t(template.nameKey), + identifier: template.identifier, + api_format: template.api_format, + base_url: template.base_url ?? '', + api_key: '', + logo_url: template.logo_url ?? '', + provider_type: template.provider_type ?? '', + }); + }; + + const handleBackToTemplateStep = () => { + setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined })); + setSelectedTemplate(null); + providerForm.resetFields(); + providerForm.setFieldsValue({ api_format: 'openai' }); + }; + + const handleSubmitProvider = async () => { + const values = await providerForm.validateFields(); + const trimmedBaseUrl = values.base_url?.trim(); + const trimmedApiKey = values.api_key?.trim(); + const trimmedLogoUrl = values.logo_url?.trim(); + const trimmedProviderType = values.provider_type?.trim(); + const payload: AIProviderPayload = { + name: (values.name || '').trim(), + identifier: (values.identifier || '').trim(), + api_format: values.api_format, + base_url: trimmedBaseUrl ? trimmedBaseUrl : null, + api_key: trimmedApiKey ? trimmedApiKey : null, + logo_url: trimmedLogoUrl ? trimmedLogoUrl : null, + provider_type: trimmedProviderType ? trimmedProviderType : null, + }; + try { + if (providerModal.editing) { + await updateProvider(providerModal.editing.id, payload); + message.success(t('Updated successfully')); + } else { + await createProvider(payload); + message.success(t('Created successfully')); + } + handleCloseProviderModal(); + await refreshData(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Save failed')); + } + }; + + const handleDeleteProvider = (provider: AIProvider) => { + Modal.confirm({ + title: t('Delete provider?'), + content: t('Deleting this provider will also remove all associated models. Continue?'), + okText: t('Confirm'), + cancelText: t('Cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteProvider(provider.id); + message.success(t('Deleted successfully')); + await refreshData(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Delete failed')); + } + }, + }); + }; + + const handleOpenModelModal = (provider: AIProvider, editing?: AIModel | null) => { + setModelModal({ open: true, provider, editing }); + setRemoteModels([]); + setSelectedRemoteModels([]); + setPullingRemoteModels(false); + setAddingRemoteModels(false); + setModelModalTab(editing ? 'manual' : 'remote'); + setRemoteSearchKeyword(''); + if (editing) { + modelForm.setFieldsValue({ + name: editing.name, + display_name: editing.display_name, + description: editing.description, + capabilities: editing.capabilities || [], + context_window: editing.context_window, + embedding_dimensions: editing.embedding_dimensions, + }); + setModelMetadata(editing.metadata ?? null); + } else { + modelForm.resetFields(); + modelForm.setFieldValue('capabilities', []); + setModelMetadata(null); + } + }; + + const handleCloseModelModal = () => { + setModelModal({ open: false }); + modelForm.resetFields(); + setModelMetadata(null); + setRemoteModels([]); + setSelectedRemoteModels([]); + setPullingRemoteModels(false); + setAddingRemoteModels(false); + setModelModalTab('remote'); + setRemoteSearchKeyword(''); + }; + + const handlePullRemoteModels = async () => { + if (!modelModal.provider) return; + setPullingRemoteModels(true); + try { + const { models: remoteList } = await fetchRemoteModels(modelModal.provider.id); + const existingNames = new Set((modelModal.provider.models || []).map((item) => item.name)); + const mapped = remoteList.map((item) => ({ + ...item, + metadata: item.metadata ?? null, + exists: existingNames.has(item.name), + })); + if (!mapped.length) { + message.info(t('No remote models found')); + } + setRemoteModels(mapped); + setSelectedRemoteModels([]); + setRemoteSearchKeyword(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Fetch failed')); + } finally { + setPullingRemoteModels(false); + } + }; + + const handleToggleRemoteSelection = (name: string, checked: boolean) => { + setSelectedRemoteModels((prev) => { + if (checked) { + return prev.includes(name) ? prev : [...prev, name]; + } + return prev.filter((item) => item !== name); + }); + }; + + const handleAddRemoteModels = async () => { + if (!modelModal.provider) return; + const candidates = remoteModels.filter( + (item) => !item.exists && selectedRemoteModels.includes(item.name), + ); + if (!candidates.length) { + message.warning(t('Select models to add')); + return; + } + setAddingRemoteModels(true); + try { + await Promise.all(candidates.map(async (item) => { + const { exists, ...candidate } = item; + void exists; + const payload: AIModelPayload = { + ...candidate, + metadata: candidate.metadata ?? null, + }; + await createModel(modelModal.provider!.id, payload); + })); + message.success(t('Added {count} models', { count: candidates.length })); + handleCloseModelModal(); + await refreshData(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Save failed')); + } finally { + setAddingRemoteModels(false); + } + }; + + const renderManualModelForm = () => ( +
+ {!modelModal.editing && ( + + + + )} + {modelModal.editing && ( + + + + )} + + + + + + + + setRemoteSearchKeyword(event.target.value)} + /> +
+ + { + const disabled = item.exists; + const checked = selectedRemoteModels.includes(item.name); + return ( + {t('Already Added')}] : undefined} + > + handleToggleRemoteSelection(item.name, event.target.checked)} + > +
+ {item.display_name || item.name} + {item.description ? ( +
{item.description}
+ ) : null} + + {(item.capabilities || []).map((cap) => ( + + {cap.toUpperCase()} + + ))} + + + {item.context_window ? {item.context_window} tokens : null} + {item.embedding_dimensions ? {item.embedding_dimensions} dims : null} + +
+
+
+ ); + }} + /> +
+ + + + + ) : null} + + ); + + const handleSubmitModel = async () => { + const values = await modelForm.validateFields(); + if (!showEmbeddingDimensions) { + values.embedding_dimensions = null; + } + const payload: AIModelPayload = { + ...values, + metadata: modelMetadata ?? null, + }; + if (!modelModal.provider) return; + try { + if (modelModal.editing) { + await updateModel(modelModal.editing.id, payload); + message.success(t('Updated successfully')); + } else { + await createModel(modelModal.provider.id, payload); + message.success(t('Created successfully')); + } + handleCloseModelModal(); + await refreshData(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Save failed')); + } + }; + + const handleDeleteModel = (model: AIModel) => { + Modal.confirm({ + title: t('Delete model?'), + content: t('This operation cannot be undone. Continue?'), + okText: t('Confirm'), + cancelText: t('Cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteModel(model.id); + message.success(t('Deleted successfully')); + await refreshData(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Delete failed')); + } + }, + }); + }; + + const updateSelection = (ability: AIAbility, value: number | null) => { + setDefaultSelections((prev) => ({ + ...prev, + [ability]: value ?? null, + })); + }; + + const allModels = useMemo(() => { + const map = new Map(); + providers.forEach((provider) => { + (provider.models || []).forEach((model) => { + map.set(model.id, { ...model, provider }); + }); + }); + return map; + }, [providers]); + + const collectionsByAbility = (ability: AIAbility) => { + return providers + .map((provider) => { + const models = (provider.models || []).filter((model) => (model.capabilities || []).includes(ability)); + if (!models.length) return null; + return { + label: ( +
+ {provider.logo_url ? ( + {provider.name} + ) : ( + + )} + {provider.name} +
+ ), + options: models.map((model) => ({ + value: model.id, + label: ( +
+ {model.display_name || model.name} + {provider.name} +
+ ), + })), + }; + }) + .filter(Boolean) as Array<{ label: ReactNode; options: Array<{ value: number; label: ReactNode }> }>; + }; + + const handleSaveDefaults = async () => { + const previousEmbedding = defaults.embedding?.id ?? null; + const nextEmbedding = defaultSelections.embedding ?? null; + const previousEmbeddingModel = previousEmbedding ? allModels.get(previousEmbedding) : undefined; + const nextEmbeddingModel = nextEmbedding ? allModels.get(nextEmbedding) : undefined; + const dimensionChanged = previousEmbeddingModel && nextEmbeddingModel + && previousEmbeddingModel.embedding_dimensions + && nextEmbeddingModel.embedding_dimensions + && previousEmbeddingModel.embedding_dimensions !== nextEmbeddingModel.embedding_dimensions; + + const proceed = async () => { + setSavingDefaults(true); + try { + const result = await updateDefaults(defaultSelections); + setDefaults(result); + const nextSelections: AIDefaultAssignments = {}; + abilityOrder.forEach((ability) => { + const model = result[ability]; + nextSelections[ability] = model ? model.id : null; + }); + setDefaultSelections(nextSelections); + message.success(t('Saved successfully')); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + message.error(msg || t('Save failed')); + } finally { + setSavingDefaults(false); + } + }; + + if (previousEmbedding !== nextEmbedding && dimensionChanged) { + Modal.confirm({ + title: t('Confirm embedding dimension change'), + content: t('Changing the embedding dimension will clear the vector database automatically. Continue?'), + okText: t('Confirm'), + cancelText: t('Cancel'), + onOk: proceed, + }); + return; + } + await proceed(); + }; + + const renderProviderCard = (provider: AIProvider) => { + const models = provider.models || []; + return ( + +
+ {provider.logo_url ? ( + {provider.name} + ) : ( + + )} +
+ {provider.name} +
+ {provider.api_format.toUpperCase()} API + {provider.base_url && ( + + + {provider.base_url} + + + )} +
+
+
+ + )} + extra={( + + + + + ) + : ( + + {!providerModal.editing && ( + + )} + + + + ); + + return ( + +
+
+ {t('AI Providers & Models')} + + {t('Manage AI providers, synchronize compatible models, and configure default capabilities across the system.')} + +
+ +
+ + + {providers.map((provider) => ( + + {renderProviderCard(provider)} + + ))} + {!providers.length && !loading ? ( + + + + + + ) : null} + + + + + {abilityOrder.map((ability) => { + const info = abilityInfo[ability]; + const options = collectionsByAbility(ability); + return ( +
+
+
+ {info.icon} +
+
+ {t(info.label)} +
{t(info.description)}
+
+
+ + + + + + + + + + + + + + + + + + + + )} + + + + + + + + )} + > + + {modelModal.editing ? ( + renderManualModelForm() + ) : ( + setModelModalTab(key as 'remote' | 'manual')} + destroyInactiveTabPane={false} + style={{ width: '100%' }} + items={[ + { + key: 'remote', + label: t('Pull Models'), + children: renderRemoteModelsTab(), + }, + { + key: 'manual', + label: t('Manual Add'), + children: renderManualModelForm(), + }, + ]} + /> + )} + + + + ); +} diff --git a/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx new file mode 100644 index 0000000..a0609ff --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/AppSettingsTab.tsx @@ -0,0 +1,47 @@ +import { Form, Input, Button } from 'antd'; +import { useI18n } from '../../../i18n'; + +interface AppConfigKey { + key: string; + label: string; + default?: string; +} + +interface AppSettingsTabProps { + config: Record; + loading: boolean; + onSave: (values: Record) => Promise; + configKeys: AppConfigKey[]; +} + +export default function AppSettingsTab({ + config, + loading, + onSave, + configKeys, +}: AppSettingsTabProps) { + const { t } = useI18n(); + + return ( +
[key, config[key] ?? def ?? ''])), + }} + onFinish={onSave} + style={{ marginTop: 24 }} + key={JSON.stringify(config)} + > + {configKeys.map(({ key, label }) => ( + + + + ))} + + + +
+ ); +} diff --git a/web/src/pages/SystemSettingsPage/components/AppearanceSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/AppearanceSettingsTab.tsx new file mode 100644 index 0000000..aeba427 --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/AppearanceSettingsTab.tsx @@ -0,0 +1,102 @@ +import { Form, Input, Button, InputNumber, Card, Radio, message } from 'antd'; +import { useTheme } from '../../../contexts/ThemeContext'; +import { useI18n } from '../../../i18n'; + +interface ThemeKeyMap { + MODE: string; + PRIMARY: string; + RADIUS: string; + TOKENS: string; + CSS: string; +} + +interface AppearanceSettingsTabProps { + config: Record; + loading: boolean; + onSave: (values: Record) => Promise; + themeKeys: ThemeKeyMap; +} + +export default function AppearanceSettingsTab({ + config, + loading, + onSave, + themeKeys, +}: AppearanceSettingsTabProps) { + const { previewTheme } = useTheme(); + const { t } = useI18n(); + + return ( +
{ + try { + const tokens = all[themeKeys.TOKENS] ? JSON.parse(all[themeKeys.TOKENS]) : undefined; + previewTheme({ + mode: all[themeKeys.MODE], + primaryColor: all[themeKeys.PRIMARY], + borderRadius: typeof all[themeKeys.RADIUS] === 'number' ? all[themeKeys.RADIUS] : undefined, + customTokens: tokens, + customCSS: all[themeKeys.CSS], + }); + } catch { + previewTheme({ + mode: all[themeKeys.MODE], + primaryColor: all[themeKeys.PRIMARY], + borderRadius: typeof all[themeKeys.RADIUS] === 'number' ? all[themeKeys.RADIUS] : undefined, + customCSS: all[themeKeys.CSS], + }); + } + }} + onFinish={async (vals) => { + if (vals[themeKeys.TOKENS]) { + try { + JSON.parse(String(vals[themeKeys.TOKENS])); + } catch { + message.error(t('Advanced tokens must be valid JSON')); + return; + } + } + await onSave(vals); + }} + style={{ marginTop: 24 }} + key={'appearance-' + JSON.stringify(config)} + > + + + + {t('Light')} + {t('Dark')} + {t('Follow System')} + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/web/src/pages/SystemSettingsPage/components/VectorDbSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/VectorDbSettingsTab.tsx new file mode 100644 index 0000000..cc5f800 --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/VectorDbSettingsTab.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Form, Button, Card, Space, Spin, Empty, Alert, Select, Input, Modal, message } from 'antd'; +import { vectorDBApi, type VectorDBStats, type VectorDBProviderMeta, type VectorDBCurrentConfig } from '../../../api/vectorDB'; +import { useI18n } from '../../../i18n'; + +interface VectorDbSettingsTabProps { + isActive: boolean; +} + +const formatBytes = (bytes?: number | null) => { + if (bytes === null || bytes === undefined) return '-'; + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const precision = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +}; + +const buildProviderConfigValues = ( + provider: VectorDBProviderMeta | undefined, + existing?: Record, +) => { + if (!provider) return {}; + const values: Record = {}; + const schema = provider.config_schema || []; + schema.forEach((field) => { + const current = existing && existing[field.key] !== undefined && existing[field.key] !== null + ? String(existing[field.key]) + : undefined; + if (current !== undefined) { + values[field.key] = current; + } else if (field.default !== undefined && field.default !== null) { + values[field.key] = String(field.default); + } else { + values[field.key] = ''; + } + }); + return values; +}; + +export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabProps) { + const [form] = Form.useForm(); + const { t } = useI18n(); + const [vectorStats, setVectorStats] = useState(null); + const [vectorStatsLoading, setVectorStatsLoading] = useState(false); + const [vectorStatsError, setVectorStatsError] = useState(null); + const [vectorProviders, setVectorProviders] = useState([]); + const [vectorConfig, setVectorConfig] = useState(null); + const [vectorConfigLoading, setVectorConfigLoading] = useState(false); + const [vectorConfigSaving, setVectorConfigSaving] = useState(false); + const [vectorMetaError, setVectorMetaError] = useState(null); + const [selectedProviderType, setSelectedProviderType] = useState(null); + + const fetchVectorStats = useCallback(async () => { + setVectorStatsLoading(true); + setVectorStatsError(null); + try { + const data = await vectorDBApi.getStats(); + setVectorStats(data); + } catch (e: any) { + const msg = e?.message || t('Load failed'); + setVectorStatsError(msg); + message.error(msg); + } finally { + setVectorStatsLoading(false); + } + }, [t]); + + const fetchVectorMeta = useCallback(async () => { + setVectorConfigLoading(true); + setVectorMetaError(null); + try { + const [providers, current] = await Promise.all([ + vectorDBApi.getProviders(), + vectorDBApi.getConfig(), + ]); + setVectorProviders(providers); + setVectorConfig(current); + + const enabled = providers.filter((item) => item.enabled); + let nextType: string | null = current?.type ?? null; + if (nextType && !providers.some((item) => item.type === nextType)) { + nextType = null; + } + if (!nextType) { + nextType = enabled[0]?.type ?? providers[0]?.type ?? null; + } + setSelectedProviderType(nextType); + + const provider = providers.find((item) => item.type === nextType); + const configValues = buildProviderConfigValues( + provider, + nextType === current?.type ? current?.config : undefined, + ); + form.setFieldsValue({ type: nextType || undefined, config: configValues }); + } catch (e: any) { + const msg = e?.message || t('Load failed'); + setVectorMetaError(msg); + message.error(msg); + } finally { + setVectorConfigLoading(false); + } + }, [form, t]); + + const handleProviderChange = useCallback((value: string) => { + setSelectedProviderType(value); + const provider = vectorProviders.find((item) => item.type === value); + const existing = value === vectorConfig?.type ? vectorConfig?.config : undefined; + const configValues = buildProviderConfigValues(provider, existing); + form.setFieldsValue({ type: value, config: configValues }); + }, [form, vectorConfig, vectorProviders]); + + const handleVectorConfigSave = useCallback(async (values: { type: string; config?: Record }) => { + if (!values?.type) { + return; + } + setVectorConfigSaving(true); + try { + const configPayload = Object.fromEntries( + Object.entries(values.config || {}) + .filter(([, val]) => val !== undefined && val !== null && String(val).trim() !== '') + .map(([key, val]) => [key, String(val)]), + ); + const response = await vectorDBApi.updateConfig({ type: values.type, config: configPayload }); + setVectorConfig(response.config); + setVectorStats(response.stats); + setVectorStatsError(null); + setSelectedProviderType(response.config.type); + const provider = vectorProviders.find((item) => item.type === response.config.type); + const mergedValues = buildProviderConfigValues(provider, response.config.config); + form.setFieldsValue({ type: response.config.type, config: mergedValues }); + message.success(t('Saved successfully')); + } catch (e: any) { + message.error(e?.message || t('Save failed')); + } finally { + setVectorConfigSaving(false); + } + }, [form, t, vectorProviders]); + + const handleClearVectorDb = useCallback(() => { + Modal.confirm({ + title: t('Confirm clear vector database?'), + content: t('This will delete all collections irreversibly.'), + okText: t('Confirm Clear'), + okType: 'danger', + cancelText: t('Cancel'), + onOk: async () => { + try { + await vectorDBApi.clearAll(); + message.success(t('Vector database cleared')); + await fetchVectorStats(); + await fetchVectorMeta(); + } catch (e: any) { + message.error(e?.message || t('Clear failed')); + } + }, + }); + }, [fetchVectorMeta, fetchVectorStats, t]); + + useEffect(() => { + if (!isActive) { + return; + } + if (!vectorProviders.length && !vectorConfigLoading) { + fetchVectorMeta(); + } + if (!vectorStats && !vectorStatsLoading) { + fetchVectorStats(); + } + }, [ + isActive, + fetchVectorMeta, + fetchVectorStats, + vectorProviders.length, + vectorConfigLoading, + vectorStats, + vectorStatsLoading, + ]); + + const vectorSectionLoading = vectorStatsLoading || vectorConfigLoading; + const selectedProvider = vectorProviders.find( + (item) => item.type === selectedProviderType || (!selectedProviderType && item.enabled), + ); + + return ( + + + +
+ {t('Current Statistics')} + +
+ {vectorSectionLoading ? ( +
+ +
+ ) : ( + <> + {vectorMetaError ? ( + + ) : null} + {vectorStats ? ( + +
+
+
{t('Collections')}
+
{vectorStats.collection_count}
+
+
+
{t('Vectors')}
+
{vectorStats.total_vectors}
+
+
+
{t('Database Size')}
+
{formatBytes(vectorStats.db_file_size_bytes)}
+
+
+
{t('Estimated Memory')}
+
{formatBytes(vectorStats.estimated_total_memory_bytes)}
+
+
+ {vectorStats.collections.length ? ( + + {vectorStats.collections.map((collection) => ( +
+ +
+ {collection.name} + + {collection.is_vector_collection && collection.dimension + ? `${t('Dimension')}: ${collection.dimension}` + : t('Non-vector collection')} + +
+
{t('Vectors')}: {collection.row_count}
+ {collection.is_vector_collection ? ( +
{t('Estimated memory')}: {formatBytes(collection.estimated_memory_bytes)}
+ ) : null} + {collection.indexes.length ? ( + + {t('Indexes')}: +
    + {collection.indexes.map((index) => ( +
  • + {index.index_name || t('Unnamed index')} + {' · '}{index.index_type || '-'} + {' · '}{index.metric_type || '-'} + {' · '}{t('Indexed rows')}: {index.indexed_rows} + {' · '}{t('Pending rows')}: {index.pending_index_rows} + {' · '}{t('Status')}: {index.state || '-'} +
  • + ))} +
+
+ ) : null} +
+
+ ))} +
+ ) : ( + + )} +
+ {t('Estimated memory is calculated as vectors x dimension x 4 bytes (float32).')} +
+
+ ) : vectorStatsError ? ( +
{vectorStatsError}
+ ) : ( + + )} +
+ + + )} + + ))} + {selectedProvider && !selectedProvider.enabled ? ( + + ) : null} + + + + + + + + + )} +
+
+
+ ); +} diff --git a/web/src/router/LayoutShell.tsx b/web/src/router/LayoutShell.tsx index 05a7e29..c0a341a 100644 --- a/web/src/router/LayoutShell.tsx +++ b/web/src/router/LayoutShell.tsx @@ -18,17 +18,26 @@ import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext import { AppWindowsLayer } from '../apps/AppWindowsLayer'; const ShellBody = memo(function ShellBody() { - const { navKey = 'files' } = useParams(); + const params = useParams<{ navKey?: string; '*': string }>(); + const navKey = params.navKey ?? 'files'; + const subPath = params['*'] ?? ''; const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(false); const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(); + const settingsTab = navKey === 'settings' ? (subPath.split('/')[0] || undefined) : undefined; return ( setCollapsed(c => !c)} activeKey={navKey} - onChange={(key) => navigate(`/${key}`)} + onChange={(key) => { + if (key === 'settings') { + navigate('/settings/appearance', { replace: true }); + } else { + navigate(`/${key}`); + } + }} /> setCollapsed(c => !c)} /> @@ -43,7 +52,12 @@ const ShellBody = memo(function ShellBody() { {navKey === 'processors' && } {navKey === 'offline' && } {navKey === 'plugins' && } - {navKey === 'settings' && } + {navKey === 'settings' && ( + navigate(`/settings/${key}`, options)} + /> + )} {navKey === 'logs' && } {navKey === 'backup' && } diff --git a/web/src/styles/ai-settings.css b/web/src/styles/ai-settings.css new file mode 100644 index 0000000..431f5c2 --- /dev/null +++ b/web/src/styles/ai-settings.css @@ -0,0 +1,361 @@ +.fx-ai-top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 28px; + border-radius: 16px; + background: linear-gradient(120deg, rgba(99, 102, 241, 0.16), rgba(167, 139, 250, 0.12)); + border: 1px solid rgba(99, 102, 241, 0.15); +} + +.fx-ai-provider-card { + border-radius: 16px; + overflow: hidden; + box-shadow: var(--ant-box-shadow-secondary); +} + +.fx-ai-provider-header { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 16px; + height: 80px; + width: 100%; +} + +.fx-ai-provider-meta { + display: flex; + align-items: center; + gap: 16px; +} + +.fx-ai-provider-logo { + width: 36px; + height: 36px; + border-radius: 12px; + object-fit: cover; + background: var(--ant-color-fill-alter); + padding: 4px; +} + +.fx-ai-provider-name { + font-size: 16px; + font-weight: 600; +} + +.fx-ai-provider-sub { + display: flex; + align-items: center; + gap: 12px; + margin-top: 4px; + color: var(--ant-color-text-tertiary); +} + +.fx-ai-model-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.fx-ai-model-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + background: var(--ant-color-fill-secondary); + border: 1px solid var(--ant-color-border); +} + +.fx-ai-model-info { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.fx-ai-model-header { + display: flex; + align-items: center; + gap: 8px; +} + +.fx-ai-model-title { + margin: 0; + font-size: 15px; +} + +.fx-ai-model-tags .ant-tag { + border-radius: 999px; + padding: 0 8px; + line-height: 20px; +} + +.fx-ai-model-meta { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fx-ai-model-desc { + line-height: 1.4; +} + +.fx-ai-model-metrics { + color: var(--ant-color-text-quaternary); +} + +.fx-ai-model-actions { + align-self: center; +} + +.fx-ai-model-actions .ant-btn { + min-width: 32px; +} + +.fx-ai-empty-card { + border-radius: 16px; + background: var(--ant-color-fill-tertiary); +} + +.fx-ai-provider-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.fx-ai-defaults-card { + border-radius: 16px; + box-shadow: var(--ant-box-shadow-secondary); +} + +.fx-ai-default-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid var(--ant-color-border-secondary); +} + +.fx-ai-default-row:last-child { + border-bottom: none; +} + +.fx-ai-default-meta { + display: flex; + gap: 16px; + align-items: center; +} + +.fx-ai-default-icon { + width: 46px; + height: 46px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: var(--ant-color-text-light-solid); +} + +.fx-ai-default-desc { + color: var(--ant-color-text-tertiary); +} + +.fx-ai-provider-option { + display: flex; + align-items: center; + gap: 8px; +} + +.fx-ai-provider-option img { + width: 20px; + height: 20px; + border-radius: 6px; + object-fit: cover; +} + +.fx-ai-model-option { + display: flex; + align-items: center; + gap: 8px; +} + +.fx-ai-model-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fx-ai-model-provider-tag { + padding: 0 8px; + border-radius: 999px; + background: var(--ant-color-fill-tertiary); + color: var(--ant-color-text-tertiary); + font-size: 12px; + line-height: 20px; + white-space: nowrap; +} + +.fx-ai-add-provider-steps { + padding: 0 8px; +} + +.fx-ai-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} + +.fx-ai-template-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-radius: 16px; + background: var(--ant-color-fill-quaternary); + border: 1px solid transparent; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.fx-ai-template-card:hover { + border-color: var(--ant-color-primary); + box-shadow: var(--ant-box-shadow-secondary); + transform: translateY(-2px); +} + +.fx-ai-template-card:focus-visible { + outline: 2px solid var(--ant-color-primary); + outline-offset: 2px; +} + +.fx-ai-template-card-main { + display: flex; + align-items: center; + gap: 16px; +} + +.fx-ai-template-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.fx-ai-template-icon.summary { + width: 56px; + height: 56px; + border-radius: 18px; + font-size: 26px; +} + +.fx-ai-template-icon img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} + +.fx-ai-template-text { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fx-ai-template-name { + font-size: 15px; + font-weight: 600; + color: var(--ant-color-text); +} + +.fx-ai-template-desc { + font-size: 12px; + color: var(--ant-color-text-tertiary); +} + +.fx-ai-template-summary { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + border-radius: 16px; + background: var(--ant-color-fill-quaternary); +} + +.fx-ai-template-summary-text { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fx-ai-template-summary-text .fx-ai-template-name { + font-size: 18px; +} + +.fx-ai-template-summary-text .fx-ai-template-desc { + font-size: 13px; +} + +.fx-ai-template-arrow { + color: var(--ant-color-text-quaternary); + font-size: 16px; +} + +.fx-ai-remote-models { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 320px; + padding: 12px; + border-radius: 12px; + background: var(--ant-color-fill-quaternary); + overflow-y: auto; +} + +.fx-ai-remote-item { + padding: 8px 0; +} + +.fx-ai-remote-item .ant-checkbox-wrapper { + width: 100%; +} + +.fx-ai-remote-item-main { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fx-ai-remote-desc { + color: var(--ant-color-text-tertiary); +} + +.fx-ai-chat { + background: linear-gradient(135deg, #805ad5, #6b46c1); +} + +.fx-ai-vision { + background: linear-gradient(135deg, #4c6ef5, #4263eb); +} + +.fx-ai-embedding { + background: linear-gradient(135deg, #f7b733, #fc4a1a); +} + +.fx-ai-rerank { + background: linear-gradient(135deg, #0ea5e9, #0284c7); +} + +.fx-ai-voice { + background: linear-gradient(135deg, #f97316, #ea580c); +} + +.fx-ai-tools { + background: linear-gradient(135deg, #ec4899, #db2777); +}