diff --git a/backend/app/api/web_search.py b/backend/app/api/web_search.py new file mode 100644 index 0000000..76d39bc --- /dev/null +++ b/backend/app/api/web_search.py @@ -0,0 +1,31 @@ +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from app.api.llm import get_current_user, get_admin_user, CurrentUser +from app.services.web_search_config_store import get_web_search_config, save_web_search_config + +router = APIRouter() + +class WebSearchConfigModel(BaseModel): + provider: str = Field(default="duckduckgo", description="Web search provider (brave, tavily, duckduckgo, searxng, jina)") + api_key: Optional[str] = Field(default="", description="API Key for the provider") + base_url: Optional[str] = Field(default="", description="Base URL for SearXNG") + max_results: int = Field(default=5, description="Maximum number of search results") + +def _sanitize_config(config: Dict[str, Any], is_admin: bool) -> Dict[str, Any]: + sanitized = config.copy() + if not is_admin: + sanitized["api_key"] = None + return sanitized + +@router.get("/web-search/config", response_model=WebSearchConfigModel) +def get_config(current_user: CurrentUser = Depends(get_current_user)): + config = get_web_search_config() + return WebSearchConfigModel(**_sanitize_config(config, current_user.is_admin)) + +@router.put("/web-search/config", response_model=WebSearchConfigModel) +def update_config(config: WebSearchConfigModel, _: CurrentUser = Depends(get_admin_user)): + config_dict = config.dict() + save_web_search_config(config_dict) + return WebSearchConfigModel(**config_dict) diff --git a/backend/app/core/nanobot.py b/backend/app/core/nanobot.py index d5f156f..a865c2f 100644 --- a/backend/app/core/nanobot.py +++ b/backend/app/core/nanobot.py @@ -33,6 +33,7 @@ from nanobot.config.schema import Config from app.api.skills import load_skills from app.core.patched_openai_compat_provider import PatchedOpenAICompatProvider from app.services.llm_cache import get_llm_configs, get_active_llm_config +from app.services.web_search_config_store import get_web_search_config from app.core.data_root import get_workspace_root @@ -103,6 +104,16 @@ class NanobotIntegration: usage = self._last_usage_by_session.get(session_id) return dict(usage) if usage else None + def _get_web_search_config(self) -> Any: + from nanobot.config.schema import WebSearchConfig + ws_dict = get_web_search_config() + return WebSearchConfig( + provider=ws_dict.get("provider", "duckduckgo"), + api_key=ws_dict.get("api_key", ""), + base_url=ws_dict.get("base_url", ""), + max_results=ws_dict.get("max_results", 5) + ) + def _need_custom_agent_for_target(self, target_config: Dict[str, Any]) -> bool: if not self.agent: return False @@ -173,7 +184,7 @@ class NanobotIntegration: model=initial_model, max_iterations=self.config.agents.defaults.max_tool_iterations, context_window_tokens=self.config.agents.defaults.context_window_tokens, - web_search_config=self.config.tools.web.search, + web_search_config=self._get_web_search_config(), web_proxy=self.config.tools.web.proxy or None, exec_config=self.config.tools.exec, cron_service=self.cron, @@ -319,7 +330,7 @@ class NanobotIntegration: model=provider.default_model, max_iterations=self.config.agents.defaults.max_tool_iterations, context_window_tokens=self.config.agents.defaults.context_window_tokens, - web_search_config=self.config.tools.web.search, + web_search_config=self._get_web_search_config(), web_proxy=self.config.tools.web.proxy or None, exec_config=self.config.tools.exec, cron_service=self.cron, diff --git a/backend/app/services/web_search_config_store.py b/backend/app/services/web_search_config_store.py new file mode 100644 index 0000000..7ad060c --- /dev/null +++ b/backend/app/services/web_search_config_store.py @@ -0,0 +1,51 @@ +import os +import json +import threading +from typing import Any, Dict + +from app.core.data_root import get_data_root + +_cache_lock = threading.RLock() +_cache_mtime: float = -1.0 +_cache_data: Dict[str, Any] = {} + +def get_config_file_path() -> str: + return str(get_data_root() / "web_search_config.json") + +def get_web_search_config() -> Dict[str, Any]: + global _cache_mtime, _cache_data + config_file = get_config_file_path() + current_mtime = os.path.getmtime(config_file) if os.path.exists(config_file) else -1.0 + + with _cache_lock: + if current_mtime != _cache_mtime: + if not os.path.exists(config_file): + _cache_data = { + "provider": "duckduckgo", + "api_key": "", + "base_url": "", + "max_results": 5 + } + else: + try: + with open(config_file, "r") as f: + _cache_data = json.load(f) + except json.JSONDecodeError: + _cache_data = { + "provider": "duckduckgo", + "api_key": "", + "base_url": "", + "max_results": 5 + } + _cache_mtime = current_mtime + return dict(_cache_data) + +def save_web_search_config(config: Dict[str, Any]) -> None: + global _cache_mtime, _cache_data + config_file = get_config_file_path() + os.makedirs(os.path.dirname(config_file), exist_ok=True) + with _cache_lock: + with open(config_file, "w") as f: + json.dump(config, f, indent=2) + _cache_data = dict(config) + _cache_mtime = os.path.getmtime(config_file) diff --git a/backend/main.py b/backend/main.py index e75808f..ad29e2a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,7 +21,7 @@ import re import os from datetime import datetime -from app.api import upload, llm, skills, users, datasources, projects, semantic, mcp, subagents, knowledge, embedding_models +from app.api import upload, llm, skills, users, datasources, projects, semantic, mcp, subagents, knowledge, embedding_models, web_search from app.connectors.postgres import postgres_connector from app.connectors.clickhouse import clickhouse_connector from app.core.artifacts import extract_artifacts @@ -77,6 +77,7 @@ app.include_router(mcp.router, prefix="/api/v1") app.include_router(subagents.router, prefix="/api/v1") app.include_router(knowledge.router, prefix="/api/v1") app.include_router(embedding_models.router, prefix="/api/v1") +app.include_router(web_search.router, prefix="/api/v1") STREAM_DELTA_CHUNK_SIZE = 48 PREVIEWABLE_TEXT_EXTENSIONS = { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5506328..e310ec1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { KnowledgeBases } from "./pages/KnowledgeBases"; import { DataSources } from "./pages/DataSources"; import { Modeling } from "./pages/Modeling"; import { Subagents } from "./pages/Subagents"; +import { WebSearchConfig } from "./pages/WebSearchConfig"; import { VerifyEmail } from "./pages/VerifyEmail"; import { useAuthStore } from "./store/authStore"; @@ -140,6 +141,14 @@ function App() { } /> + + + + + + } /> + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4f493c5..ebc8ab6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1073,6 +1073,18 @@ function SidebarBody() { > {t('voiceSettings')} + {user?.is_admin && ( + + )} )} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9f3b1e3..351acad 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -383,5 +383,17 @@ "voiceSettingsDisabledHint": "Enable voice input first, then configure server URL", "voiceInputDisabledHint": "Please enable voice input first", "voiceInputNotEnabled": "Voice input is disabled. Enable it from profile menu -> More -> Voice Input Settings first.", - "embeddingModels": "Embedding Models" + "embeddingModels": "Embedding Models", + "webSearchConfig": "Web Search Config", + "configureWebSearchProvider": "Configure the default web search provider and settings for the AI agent.", + "selectProvider": "Select a provider", + "enterApiKey": "Enter API Key", + "apiKeyRequiredFor": "An API Key is required for {{provider}}", + "baseUrl": "Base URL", + "baseUrlRequiredFor": "A Base URL is required for {{provider}}", + "maxResults": "Max Results", + "maxResultsDescription": "Maximum number of search results to return (1-20)", + "failedToLoadConfig": "Failed to load configuration", + "failedToSaveConfig": "Failed to save configuration", + "configSaved": "Configuration saved successfully. Note: Active agents may require a restart to pick up the new configuration." } diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index b6549d4..55cb4da 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -384,5 +384,17 @@ "voiceSettingsDisabledHint": "请先开启语音输入,再配置服务地址", "voiceInputDisabledHint": "请先开启语音输入", "voiceInputNotEnabled": "语音输入未开启,请先到左下角用户名 -> 更多 -> 语音输入配置中开启", - "embeddingModels": "Embedding 模型" + "embeddingModels": "Embedding 模型", + "webSearchConfig": "Web 搜索配置", + "configureWebSearchProvider": "配置 AI 智能体默认的网页搜索提供商及参数。", + "selectProvider": "选择提供商", + "enterApiKey": "输入 API Key", + "apiKeyRequiredFor": "{{provider}} 需要提供 API Key", + "baseUrl": "基础 URL", + "baseUrlRequiredFor": "{{provider}} 需要提供基础 URL", + "maxResults": "最大结果数", + "maxResultsDescription": "搜索返回的最大结果数量 (1-20)", + "failedToLoadConfig": "加载配置失败", + "failedToSaveConfig": "保存配置失败", + "configSaved": "配置保存成功。注意:活跃的智能体可能需要重启新会话才能应用新配置。" } diff --git a/frontend/src/pages/WebSearchConfig.tsx b/frontend/src/pages/WebSearchConfig.tsx new file mode 100644 index 0000000..a55f48e --- /dev/null +++ b/frontend/src/pages/WebSearchConfig.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Save, Loader2, Globe } from "lucide-react"; +import { api } from "@/lib/api"; + +interface WebSearchConfig { + provider: string; + api_key?: string; + base_url?: string; + max_results: number; +} + +export function WebSearchConfig() { + const { t } = useTranslation(); + const [config, setConfig] = useState({ provider: 'duckduckgo', max_results: 5 }); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const fetchConfig = async () => { + setIsLoading(true); + try { + const data = await api.get('/api/v1/web-search/config'); + setConfig({ + provider: data.provider || 'duckduckgo', + api_key: data.api_key || '', + base_url: data.base_url || '', + max_results: data.max_results || 5 + }); + } catch (err: unknown) { + console.error("Failed to load web search config", err); + setError(t('failedToLoadConfig', 'Failed to load configuration')); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchConfig(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSave = async () => { + setError(''); + setSuccess(''); + setIsSaving(true); + try { + await api.put('/api/v1/web-search/config', config); + setSuccess(t('configSaved', 'Configuration saved successfully. Note: Active agents may require a restart to pick up the new configuration.')); + } catch (err: unknown) { + console.error("Failed to save web search config", err); + const errorMessage = err instanceof Error ? err.message : t('failedToSaveConfig', 'Failed to save configuration'); + setError(errorMessage); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const needsApiKey = ['brave', 'tavily', 'jina'].includes(config.provider); + const needsBaseUrl = config.provider === 'searxng'; + + return ( +
+
+
+ + {t('webSearchConfig', 'Web Search Configuration')} +
+
+ +
+
+ {error &&
{error}
} + {success &&
{success}
} + + + + {t('webSearchConfig', 'Web Search Configuration')} + {t('configureWebSearchProvider', 'Configure the default web search provider and settings for the AI agent.')} + + +
+ + +
+ + {needsApiKey && ( +
+ + setConfig({ ...config, api_key: e.target.value })} + /> +

{t('apiKeyRequiredFor', 'An API Key is required for {{provider}}', { provider: config.provider })}

+
+ )} + + {needsBaseUrl && ( +
+ + setConfig({ ...config, base_url: e.target.value })} + /> +

{t('baseUrlRequiredFor', 'A Base URL is required for {{provider}}', { provider: config.provider })}

+
+ )} + +
+ + setConfig({ ...config, max_results: parseInt(e.target.value) || 5 })} + /> +

{t('maxResultsDescription', 'Maximum number of search results to return (1-20)')}

+
+ +
+ + + +
+
+
+
+ ); +}