351 lines
11 KiB
Python
351 lines
11 KiB
Python
"""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)
|