"""Search Hub — 统一搜索服务 + Web UI + Admin""" import os from flask import ( Flask, request, jsonify, render_template, Response, stream_with_context, ) from config import load_config from hub.router import SearchRouter from hub.config_manager import ( get_source_config, update_source_config, get_source_schema, mask_key, ) from hub.quota import check_tavily_usage, check_baidu_vdb_quota from providers.tavily_provider import TavilyProvider from providers.duckduckgo_provider import DuckDuckGoProvider from providers.baidu_provider import BaiduProvider from providers.ai_provider import AIProvider from providers.searxng_provider import SearXNGProvider app = Flask(__name__) # 初始化搜索路由器 _raw_config = load_config() _providers = { 'tavily': TavilyProvider(_raw_config), 'duckduckgo': DuckDuckGoProvider(_raw_config), 'baidu': BaiduProvider(_raw_config, mode='web'), 'baidu-intelligent': BaiduProvider(_raw_config, mode='intelligent'), 'ai': AIProvider(_raw_config), 'searxng': SearXNGProvider(_raw_config), } router = SearchRouter(_providers) # 搜索历史 search_history = [] def _reload_providers(): """重新加载所有 provider 配置(管理面板修改密钥后调用)""" global _raw_config, _providers, router _raw_config = load_config() _providers = { 'tavily': TavilyProvider(_raw_config), 'duckduckgo': DuckDuckGoProvider(_raw_config), 'baidu': BaiduProvider(_raw_config, mode='web'), 'baidu-intelligent': BaiduProvider(_raw_config, mode='intelligent'), 'ai': AIProvider(_raw_config), 'searxng': SearXNGProvider(_raw_config), } router = SearchRouter(_providers) # ===================== 统一搜索 API ===================== @app.route('/api/search', methods=['POST']) def api_search(): """统一搜索接口""" data = request.get_json() query = (data.get('query') or '').strip() source = (data.get('source') or 'auto').strip() max_results = int(data.get('max_results', 10)) if not query: return jsonify({'error': '请输入搜索词'}), 400 result = router.search(query, source=source, max_results=max_results) # 记录历史 if not result.get('error'): if query in search_history: search_history.remove(query) search_history.insert(0, query) if len(search_history) > 20: search_history.pop() return jsonify(result) @app.route('/api/sources', methods=['GET']) def api_sources(): """查询可用搜索源""" return jsonify({ 'sources': router.get_sources(), }) @app.route('/api/status', methods=['GET']) def api_status(): """健康检查 + 统计""" sources = router.get_sources() available = [s['name'] for s in sources if s['available']] return jsonify({ 'status': 'ok', 'version': '1.0', 'available_sources': available, 'history_count': len(search_history), }) @app.route('/api/history', methods=['GET']) def api_history(): return jsonify({'history': search_history}) # ===================== 管理 API ===================== @app.route('/api/admin/sources/', methods=['GET']) def admin_get_source(name): """获取单个源的可配置字段(密钥脱敏)""" provider = _providers.get(name) if not provider: return jsonify({'error': f'搜索源 "{name}" 不存在'}), 404 schema = get_source_schema(name) local_cfg = get_source_config(name) # 处理字段名到配置 key 的映射 config_key = name if name == 'ai': config_key = 'opencodezen' elif name == 'baidu-intelligent': config_key = 'baidu' # 共享百度配置 hermes_cfg = _raw_config.get(config_key, {}) fields = [] for field in schema: key = field['key'] # 优先取本地配置,否则取 Hermes 配置 raw_value = local_cfg.get(key) or hermes_cfg.get(key) or field.get('default', '') fields.append({ 'key': key, 'label': field['label'], 'type': field['type'], 'required': field.get('required', False), 'value': mask_key(raw_value) if field['type'] == 'password' else raw_value, 'has_value': bool(raw_value), }) return jsonify({ 'name': name, 'display_name': provider.display_name, 'available': provider.is_available(), 'fields': fields, }) @app.route('/api/admin/sources/', methods=['POST']) def admin_update_source(name): """更新某个源的配置""" provider = _providers.get(name) if not provider: return jsonify({'error': f'搜索源 "{name}" 不存在'}), 404 data = request.get_json() if not data: return jsonify({'error': '请提供配置数据'}), 400 # 获取该源的可配字段定义 schema = get_source_schema(name) allowed_keys = {f['key'] for f in schema} # 过滤出允许的字段 updates = {k: v for k, v in data.items() if k in allowed_keys} if not updates: return jsonify({'error': '没有可更新的配置字段'}), 400 # 保存到本地配置 for key, value in updates.items(): update_source_config(name, {key: value}) # 重新加载 providers _reload_providers() return jsonify({ 'success': True, 'message': f'{provider.display_name} 配置已更新', 'updated': list(updates.keys()), }) @app.route('/api/admin/usage', methods=['GET']) def admin_usage(): """查询各服务剩余额度(异步调用,前端不阻塞)""" tavily_cfg = get_source_config('tavily') or {} tavily_api_key = tavily_cfg.get('api_key') or _raw_config.get('tavily', {}).get('api_key') baidu_cfg = get_source_config('baidu') or {} vdb_ak = baidu_cfg.get('vdb_access_key') or '' vdb_sk = baidu_cfg.get('vdb_secret_key') or '' # 后端异步执行,不阻塞 Flask 进程 from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=2) as pool: tavily_fut = pool.submit(check_tavily_usage, tavily_api_key) baidu_fut = pool.submit(check_baidu_vdb_quota, vdb_ak, vdb_sk) result = { 'tavily': tavily_fut.result(timeout=15), 'baidu_vdb': baidu_fut.result(timeout=15), } return jsonify(result) @app.route('/api/ai-summarize', methods=['POST']) def api_ai_summarize(): """非流式 AI 总结""" data = request.get_json() query = (data.get('query') or '').strip() results = data.get('results', []) if not query: return jsonify({'error': '请输入搜索词'}), 400 if not results: return jsonify({'error': '没有搜索结果可总结'}), 400 ai = _providers.get('ai') if not ai or not ai.is_available(): return jsonify({'error': 'AI 总结未配置', 'summary': ''}), 400 result = ai.summarize(query, results) return jsonify(result) @app.route('/api/ai-summarize/stream', methods=['POST']) def api_ai_summarize_stream(): """流式 AI 总结(SSE)""" data = request.get_json() query = (data.get('query') or '').strip() results = data.get('results', []) if not query: return jsonify({'error': '请输入搜索词'}), 400 if not results: return jsonify({'error': '没有搜索结果可总结'}), 400 ai = _providers.get('ai') if not ai or not ai.is_available(): return jsonify({'error': 'AI 总结未配置'}), 400 return Response( stream_with_context(ai.summarize_stream(query, results)), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', 'Connection': 'keep-alive', }, ) # ===================== API 文档 ===================== API_DOCS = [ { 'method': 'POST', 'path': '/api/search', 'desc': '统一搜索', 'body': { 'query': '搜索词', 'source': 'auto/tavily/baidu/searxng/baidu-intelligent 或逗号组合', 'max_results': 10, }, 'example': "curl -X POST http://localhost:8650/api/search \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"深度学习\",\"source\":\"auto\",\"max_results\":5}'", }, { 'method': 'GET', 'path': '/api/sources', 'desc': '查看可用搜索源', 'example': 'curl http://localhost:8650/api/sources', }, { 'method': 'GET', 'path': '/api/status', 'desc': '服务状态', 'example': 'curl http://localhost:8650/api/status', }, { 'method': 'GET', 'path': '/api/history', 'desc': '搜索历史', 'example': 'curl http://localhost:8650/api/history', }, { 'method': 'POST', 'path': '/api/ai-summarize', 'desc': 'AI 总结(非流式)', 'body': { 'query': '搜索词', 'results': [{'title': '', 'url': '', 'content': ''}], }, 'example': "curl -X POST http://localhost:8650/api/ai-summarize \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"test\",\"results\":[{\"title\":\"T\",\"url\":\"https://x.com\",\"content\":\"C\"}]}'", }, { 'method': 'POST', 'path': '/api/ai-summarize/stream', 'desc': 'AI 总结(流式 SSE)', 'body': { 'query': '搜索词', 'results': [{'title': '', 'url': '', 'content': ''}], }, 'example': "curl -N -X POST http://localhost:8650/api/ai-summarize/stream \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"test\",\"results\":[{\"title\":\"T\",\"url\":\"https://x.com\",\"content\":\"C\"}]}'", }, { 'method': 'GET', 'path': '/api/admin/sources/{name}', 'desc': '获取搜索源配置(字段定义)', 'example': "curl http://localhost:8650/api/admin/sources/tavily", }, { 'method': 'POST', 'path': '/api/admin/sources/{name}', 'desc': '更新搜索源配置', 'example': "curl -X POST http://localhost:8650/api/admin/sources/tavily \\\n -H 'Content-Type: application/json' \\\n -d '{\"api_key\":\"sk-xxx\"}'", }, ] @app.route('/api/docs') def api_docs(): return jsonify({'endpoints': API_DOCS}) @app.route('/api') def api_index(): """API 文档入口""" return jsonify({ 'name': 'Search Hub API', 'version': '1.0', 'endpoints': '/api/docs', 'web_ui': '/web', }) # ===================== 页面路由 ===================== @app.route('/') @app.route('/web') def web_index(): """Web 搜索界面""" sources = router.get_sources() return render_template('index.html', sources=sources) @app.route('/admin') def admin_index(): """管理面板""" sources = router.get_sources() return render_template('admin.html', sources=sources) # ===================== 启动 ===================== if __name__ == '__main__': port = int(os.environ.get('PORT', 8650)) app.run(host='0.0.0.0', port=port, debug=False)