Files
search-hub/app.py

351 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<name>', 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/<name>', 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)