feat: add web search config

This commit is contained in:
qixinbo
2026-03-29 19:34:58 +08:00
parent fb4fc7437f
commit 99b654bbd2
9 changed files with 308 additions and 5 deletions
+31
View File
@@ -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)
+13 -2
View File
@@ -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,
@@ -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)
+2 -1
View File
@@ -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 = {
+9
View File
@@ -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() {
</ProtectedRoute>
} />
<Route path="/web-search-config" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
<WebSearchConfig />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/knowledge-bases" element={
<ProtectedRoute>
<MainLayout>
+12
View File
@@ -1073,6 +1073,18 @@ function SidebarBody() {
>
{t('voiceSettings')}
</button>
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground/80 hover:bg-muted transition-colors relative z-10"
onClick={() => {
navigate("/web-search-config");
setShowUserMenu(false);
setShowMoreSubmenu(false);
}}
>
{t('webSearchConfig', 'Web Search Config')}
</button>
)}
</div>
)}
</div>
+13 -1
View File
@@ -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."
}
+13 -1
View File
@@ -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": "配置保存成功。注意:活跃的智能体可能需要重启新会话才能应用新配置。"
}
+164
View File
@@ -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<WebSearchConfig>({ 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<WebSearchConfig>('/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 (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
const needsApiKey = ['brave', 'tavily', 'jina'].includes(config.provider);
const needsBaseUrl = config.provider === 'searxng';
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Globe className="h-5 w-5 text-indigo-500" />
{t('webSearchConfig', 'Web Search Configuration')}
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="grid gap-6 max-w-4xl mx-auto">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="text-xl">{t('webSearchConfig', 'Web Search Configuration')}</CardTitle>
<CardDescription>{t('configureWebSearchProvider', 'Configure the default web search provider and settings for the AI agent.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('provider', 'Provider')}</Label>
<Select
value={config.provider}
onValueChange={(val) => setConfig({ ...config, provider: val })}
>
<SelectTrigger>
<SelectValue placeholder={t('selectProvider', 'Select a provider')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="duckduckgo">DuckDuckGo (Free, No API Key required)</SelectItem>
<SelectItem value="brave">Brave Search</SelectItem>
<SelectItem value="tavily">Tavily</SelectItem>
<SelectItem value="jina">Jina Reader</SelectItem>
<SelectItem value="searxng">SearXNG</SelectItem>
</SelectContent>
</Select>
</div>
{needsApiKey && (
<div className="space-y-2">
<Label>{t('apiKey', 'API Key')}</Label>
<Input
type="password"
placeholder={t('enterApiKey', 'Enter API Key')}
value={config.api_key || ''}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
/>
<p className="text-xs text-muted-foreground">{t('apiKeyRequiredFor', 'An API Key is required for {{provider}}', { provider: config.provider })}</p>
</div>
)}
{needsBaseUrl && (
<div className="space-y-2">
<Label>{t('baseUrl', 'Base URL')}</Label>
<Input
placeholder="e.g. http://localhost:8080"
value={config.base_url || ''}
onChange={(e) => setConfig({ ...config, base_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">{t('baseUrlRequiredFor', 'A Base URL is required for {{provider}}', { provider: config.provider })}</p>
</div>
)}
<div className="space-y-2">
<Label>{t('maxResults', 'Max Results')}</Label>
<Input
type="number"
min={1}
max={20}
value={config.max_results}
onChange={(e) => setConfig({ ...config, max_results: parseInt(e.target.value) || 5 })}
/>
<p className="text-xs text-muted-foreground">{t('maxResultsDescription', 'Maximum number of search results to return (1-20)')}</p>
</div>
</CardContent>
<CardFooter className="bg-muted/50/50 border-t border-border pt-6">
<Button onClick={handleSave} className="ml-auto bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('saveSettings', 'Save Settings')}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}