init: Search Hub - 统一多搜索引擎聚合服务
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.log
|
||||
350
app.py
Normal file
350
app.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""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)
|
||||
129
config.py
Normal file
129
config.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""从 Hermes 配置文件中读取各搜索源所需密钥"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
|
||||
HERMES_CONFIG = '/root/.hermes/config.yaml'
|
||||
HERMES_ENV = '/root/.hermes/.env'
|
||||
SEARCH_HUB_CONFIG = '/root/agentspace/projects/search-hub/config.yaml'
|
||||
|
||||
|
||||
def load_config():
|
||||
"""加载所有搜索源配置,返回 dict"""
|
||||
# 从 hermes config 读取
|
||||
with open(HERMES_CONFIG, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# 读取 .env
|
||||
env_vars = {}
|
||||
if os.path.exists(HERMES_ENV):
|
||||
with open(HERMES_ENV, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
if '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
env_vars[k.strip()] = v.strip()
|
||||
|
||||
def resolve_api_key(raw_key):
|
||||
if not raw_key:
|
||||
return None
|
||||
m = re.match(r'\$\{(\w+)\}', str(raw_key))
|
||||
if m:
|
||||
var_name = m.group(1)
|
||||
return env_vars.get(var_name) or os.environ.get(var_name)
|
||||
return raw_key
|
||||
|
||||
# === Tavily ===
|
||||
tavily_cfg = config.get('search', {}).get('tavily', {})
|
||||
tavily_api_key = resolve_api_key(tavily_cfg.get('api_key'))
|
||||
tavily_base_url = tavily_cfg.get('base_url', 'https://api.tavily.com')
|
||||
tavily_max_results = tavily_cfg.get('default_max_results', 10)
|
||||
tavily_depth = tavily_cfg.get('default_search_depth', 'basic')
|
||||
|
||||
# === OpenCodeZen ===
|
||||
opencodezen_key = None
|
||||
opencodezen_base_url = 'https://opencode.ai/zen/go/v1'
|
||||
opencodezen_model = 'deepseek-v4-flash'
|
||||
|
||||
for provider in config.get('custom_providers', []):
|
||||
if provider.get('name') == 'OpenCodeZen':
|
||||
opencodezen_key = resolve_api_key(provider.get('api_key'))
|
||||
opencodezen_base_url = provider.get('base_url', opencodezen_base_url)
|
||||
opencodezen_model = provider.get('model', opencodezen_model)
|
||||
break
|
||||
|
||||
if not opencodezen_key:
|
||||
opencodezen_key = env_vars.get('OPENCODE_ZEN_API_KEY')
|
||||
|
||||
# === Search Hub 自身配置(可选,可覆盖 Hermes 配置)===
|
||||
hub_config = {}
|
||||
if os.path.exists(SEARCH_HUB_CONFIG):
|
||||
with open(SEARCH_HUB_CONFIG, 'r') as f:
|
||||
hub_config = yaml.safe_load(f) or {}
|
||||
|
||||
# 应用本地配置覆盖
|
||||
local_sources = hub_config.get('sources', {})
|
||||
|
||||
# Tavily 覆盖
|
||||
if 'tavily' in local_sources:
|
||||
tavily_local = local_sources['tavily']
|
||||
if tavily_local.get('api_key'):
|
||||
tavily_api_key = tavily_local['api_key']
|
||||
if tavily_local.get('base_url'):
|
||||
tavily_base_url = tavily_local['base_url']
|
||||
|
||||
# OpenCodeZen/AI 覆盖
|
||||
if 'ai' in local_sources:
|
||||
ai_local = local_sources['ai']
|
||||
if ai_local.get('api_key'):
|
||||
opencodezen_key = ai_local['api_key']
|
||||
if ai_local.get('base_url'):
|
||||
opencodezen_base_url = ai_local['base_url']
|
||||
if ai_local.get('model'):
|
||||
opencodezen_model = ai_local['model']
|
||||
|
||||
# 百度配置(来自本地 config.yaml)
|
||||
baidu_api_key = ''
|
||||
baidu_intelligent_url = 'https://qianfan.baidubce.com/v2/ai_search/chat/completions'
|
||||
baidu_web_search_url = 'https://qianfan.baidubce.com/v2/ai_search/web_search'
|
||||
|
||||
if 'baidu' in local_sources:
|
||||
baidu_local = local_sources['baidu']
|
||||
if baidu_local.get('api_key'):
|
||||
baidu_api_key = baidu_local['api_key']
|
||||
if baidu_local.get('intelligent_url'):
|
||||
baidu_intelligent_url = baidu_local['intelligent_url']
|
||||
if baidu_local.get('web_search_url'):
|
||||
baidu_web_search_url = baidu_local['web_search_url']
|
||||
|
||||
# === SearXNG ===
|
||||
searxng_base_url = 'http://localhost:8888'
|
||||
if 'searxng' in local_sources:
|
||||
searxng_local = local_sources['searxng']
|
||||
if searxng_local.get('base_url'):
|
||||
searxng_base_url = searxng_local['base_url']
|
||||
|
||||
return {
|
||||
'tavily': {
|
||||
'api_key': tavily_api_key,
|
||||
'base_url': tavily_base_url,
|
||||
'max_results': tavily_max_results,
|
||||
'depth': tavily_depth,
|
||||
},
|
||||
'opencodezen': {
|
||||
'api_key': opencodezen_key,
|
||||
'base_url': opencodezen_base_url,
|
||||
'model': opencodezen_model,
|
||||
},
|
||||
'hub': hub_config,
|
||||
'baidu': {
|
||||
'api_key': baidu_api_key,
|
||||
'intelligent_url': baidu_intelligent_url,
|
||||
'web_search_url': baidu_web_search_url,
|
||||
},
|
||||
'searxng': {
|
||||
'base_url': searxng_base_url,
|
||||
},
|
||||
}
|
||||
7
config.yaml
Normal file
7
config.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
sources:
|
||||
searxng:
|
||||
base_url: "http://localhost:8888"
|
||||
baidu:
|
||||
api_key: "bce-v3/ALTAK-zLRgL8GKvnyFoh4mkpdmB/f4ae66e80f99d545443ee8ae30e81a9f0db34b37"
|
||||
intelligent_url: "https://qianfan.baidubce.com/v2/ai_search/chat/completions"
|
||||
web_search_url: "https://qianfan.baidubce.com/v2/ai_search/web_search"
|
||||
72
docs/api.md
Normal file
72
docs/api.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# API 文档
|
||||
|
||||
搜索入口: `http://localhost:8650`
|
||||
|
||||
## 搜索
|
||||
|
||||
```
|
||||
POST /api/search
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"query": "搜索词",
|
||||
"source": "auto", // auto / tavily / baidu / searxng / tavily,baidu
|
||||
"max_results": 10
|
||||
}
|
||||
```
|
||||
|
||||
## 搜索源列表
|
||||
|
||||
```
|
||||
GET /api/sources
|
||||
```
|
||||
|
||||
## 服务状态
|
||||
|
||||
```
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
## AI 总结(流式 SSE)
|
||||
|
||||
```
|
||||
POST /api/ai-summarize/stream
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"query": "搜索词",
|
||||
"results": [{"title": "...", "url": "...", "content": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
响应为 Server-Sent Events:
|
||||
|
||||
```
|
||||
event: delta
|
||||
data: {"content": "..."}
|
||||
|
||||
event: meta
|
||||
data: {"model": "...", "elapsed": 1.23}
|
||||
|
||||
event: error
|
||||
data: {"error": "..."}
|
||||
```
|
||||
|
||||
## 管理 API
|
||||
|
||||
### 获取源配置
|
||||
```
|
||||
GET /api/admin/sources/{name}
|
||||
```
|
||||
|
||||
### 更新源配置
|
||||
```
|
||||
POST /api/admin/sources/{name}
|
||||
Content-Type: application/json
|
||||
|
||||
{"api_key": "sk-xxx", "base_url": "https://..."}
|
||||
```
|
||||
|
||||
## 完整 API 列表
|
||||
|
||||
访问管理面板或 `GET /api/docs` 获取完整列表及 curl 示例。
|
||||
78
docs/architecture.md
Normal file
78
docs/architecture.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Search Hub 架构
|
||||
|
||||
## 项目定位
|
||||
|
||||
统一多搜索引擎聚合服务,提供 Web UI + RESTful API + 管理面板。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
search-hub/
|
||||
├── app.py # Flask 主入口 — API 路由 / 页面路由 / 启动
|
||||
├── config.py # 配置加载(Hermes 配置 → config.yaml → 管理面板)
|
||||
├── config.yaml # 搜索源密钥 / base_url 等配置
|
||||
├── requirements.txt # Python 依赖
|
||||
│
|
||||
├── providers/ # 搜索源适配器层
|
||||
│ ├── base.py # 抽象基类 BaseProvider + SearchResult
|
||||
│ ├── tavily_provider.py # Tavily API
|
||||
│ ├── baidu_provider.py # 百度千帆(网页搜索 / 智能检索)
|
||||
│ ├── duckduckgo_provider.py # DuckDuckGo(免费,默认关闭)
|
||||
│ ├── searxng_provider.py # SearXNG 自托管元搜索引擎
|
||||
│ └── ai_provider.py # AI 总结服务(非搜索源)
|
||||
│
|
||||
├── hub/ # 核心逻辑层
|
||||
│ ├── router.py # 搜索路由器 — 多源路由 / 去重合并 / 自动选择
|
||||
│ └── config_manager.py # 配置管理 — 读写 config.yaml / 字段 schema / 密钥脱敏
|
||||
│
|
||||
├── templates/ # Jinja2 模板
|
||||
│ ├── index.html # 搜索页面(Deepseek 风格 UI)
|
||||
│ └── admin.html # 管理面板 + API 文档
|
||||
│
|
||||
├── static/ # 前端资源
|
||||
│ ├── css/style.css # 全局样式
|
||||
│ ├── js/app.js # 搜索页交互逻辑
|
||||
│ └── js/admin.js # 管理面板交互逻辑
|
||||
│
|
||||
└── docs/ # 项目文档
|
||||
├── architecture.md # 本文件 — 技术架构与设计决策
|
||||
└── providers.md # 搜索源接入指南
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
用户输入
|
||||
│
|
||||
▼
|
||||
app.py: /api/search
|
||||
│
|
||||
▼
|
||||
hub/router.py: SearchRouter.search()
|
||||
│ ├─ source='auto' → 按优先级遍历可用源,去重合并
|
||||
│ ├─ source='tavily' → 单一源搜索
|
||||
│ └─ source='a,b,c' → 多源并发搜索
|
||||
│
|
||||
▼
|
||||
providers/*.search() ← 各搜索源适配器调用外部 API
|
||||
│
|
||||
▼
|
||||
SearchResult 列表 → 去重 → 排序 → JSON 响应
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
管理面板保存 > config.yaml > Hermes 配置
|
||||
|
||||
修改配置后调用 `_reload_providers()` 热加载,无需重启。
|
||||
|
||||
## 搜索源优先级
|
||||
|
||||
| 源 | 优先级 | API Key | 说明 |
|
||||
|---|---|---|---|
|
||||
| Tavily | 10 | 需要 | 默认首选 |
|
||||
| 百度搜索 | 20 | 需要 | 千帆网页搜索 |
|
||||
| 百度智能检索 | 21 | 需要 | 千帆 AI 检索 |
|
||||
| SearXNG | 25 | 不需要 | 自托管元搜索 |
|
||||
| DuckDuckGo | 30 | 不需要 | 国内不可用,默认关闭 |
|
||||
| AI 总结 | 50 | 需要 | 仅用于总结,非搜索源 |
|
||||
107
docs/providers.md
Normal file
107
docs/providers.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 搜索源接入指南
|
||||
|
||||
## 新增一个搜索源
|
||||
|
||||
### 1. 创建 Provider 类
|
||||
|
||||
在 `providers/` 下新建文件,继承 `BaseProvider`:
|
||||
|
||||
```python
|
||||
from providers.base import BaseProvider, SearchResult
|
||||
|
||||
class MyProvider(BaseProvider):
|
||||
name = 'mysearch' # 唯一标识
|
||||
display_name = 'MySearch' # 展示名称
|
||||
needs_api_key = False # 是否需要 API key
|
||||
enabled = True # 是否默认启用
|
||||
priority = 35 # 优先级(越小越优先)
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
# 从 config 中读取配置
|
||||
mc = config.get('mysearch', {})
|
||||
self.api_key = mc.get('api_key')
|
||||
self.base_url = mc.get('base_url', 'https://api.mysearch.com')
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True # 或 return bool(self.api_key)
|
||||
|
||||
def search(self, query: str, max_results: int = 10) -> list:
|
||||
# 调用外部搜索 API
|
||||
results = []
|
||||
# ... 解析响应 ...
|
||||
results.append(SearchResult(
|
||||
title='标题',
|
||||
url='https://example.com',
|
||||
content='摘要内容',
|
||||
score=0.8,
|
||||
source=self.name,
|
||||
published_date='2024-01-01',
|
||||
))
|
||||
return results[:max_results]
|
||||
```
|
||||
|
||||
### 2. 注册到 app.py
|
||||
|
||||
两处注册:初始化时 + `_reload_providers()` 中
|
||||
|
||||
```python
|
||||
from providers.my_provider import MyProvider
|
||||
|
||||
_providers = {
|
||||
# ... 已有源 ...
|
||||
'mysearch': MyProvider(_raw_config),
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 添加配置加载(config.py)
|
||||
|
||||
在 `load_config()` 返回值中加入配置:
|
||||
|
||||
```python
|
||||
'mysearch': {
|
||||
'api_key': my_api_key,
|
||||
'base_url': my_base_url,
|
||||
},
|
||||
```
|
||||
|
||||
### 4. 添加字段 Schema(hub/config_manager.py)
|
||||
|
||||
在 `get_source_schema()` 中加入字段定义:
|
||||
|
||||
```python
|
||||
'mysearch': [
|
||||
{'key': 'api_key', 'label': 'API Key', 'type': 'password', 'required': True},
|
||||
{'key': 'base_url', 'label': 'API 地址', 'type': 'text', 'required': False},
|
||||
],
|
||||
```
|
||||
|
||||
### 5. 添加本地配置(config.yaml)
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
mysearch:
|
||||
api_key: "sk-xxx"
|
||||
base_url: "https://api.mysearch.com"
|
||||
```
|
||||
|
||||
## Provider 基类属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | str | 唯一标识,用于路由 |
|
||||
| `display_name` | str | UI 展示名 |
|
||||
| `needs_api_key` | bool | 管理面板是否显示编辑按钮 |
|
||||
| `enabled` | bool | auto 模式是否自动使用 |
|
||||
| `priority` | int | 越小越优先(auto 模式排序用) |
|
||||
|
||||
## SearchResult 字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `title` | 标题 |
|
||||
| `url` | 链接 |
|
||||
| `content` | 摘要/内容 |
|
||||
| `score` | 相关性分数(0~1),用于去重排序 |
|
||||
| `source` | 来源标识 |
|
||||
| `published_date` | 发布日期(可选) |
|
||||
0
hub/__init__.py
Normal file
0
hub/__init__.py
Normal file
82
hub/config_manager.py
Normal file
82
hub/config_manager.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Search Hub 配置管理器 — 读写本地配置文件"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
|
||||
CONFIG_PATH = '/root/agentspace/projects/search-hub/config.yaml'
|
||||
|
||||
|
||||
def load_hub_config() -> dict:
|
||||
"""加载本地配置"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_hub_config(cfg: dict):
|
||||
"""保存配置到文件"""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, 'w') as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def get_source_config(name: str) -> dict:
|
||||
"""获取单个源的配置"""
|
||||
cfg = load_hub_config()
|
||||
sources = cfg.get('sources', {})
|
||||
return sources.get(name, {})
|
||||
|
||||
|
||||
def update_source_config(name: str, updates: dict):
|
||||
"""更新单个源的配置并保存"""
|
||||
cfg = load_hub_config()
|
||||
if 'sources' not in cfg:
|
||||
cfg['sources'] = {}
|
||||
if name not in cfg['sources']:
|
||||
cfg['sources'][name] = {}
|
||||
cfg['sources'][name].update(updates)
|
||||
save_hub_config(cfg)
|
||||
|
||||
|
||||
def get_source_schema(name: str) -> list:
|
||||
"""返回某个源的可配置字段定义"""
|
||||
schemas = {
|
||||
'tavily': [
|
||||
{'key': 'api_key', 'label': 'API Key', 'type': 'password', 'required': True},
|
||||
{'key': 'base_url', 'label': 'API 地址', 'type': 'text', 'required': False,
|
||||
'default': 'https://api.tavily.com'},
|
||||
],
|
||||
'ai': [
|
||||
{'key': 'api_key', 'label': 'API Key', 'type': 'password', 'required': True},
|
||||
{'key': 'base_url', 'label': 'API 地址', 'type': 'text', 'required': False,
|
||||
'default': 'https://opencode.ai/zen/go/v1'},
|
||||
{'key': 'model', 'label': '模型名称', 'type': 'text', 'required': False,
|
||||
'default': 'deepseek-v4-flash'},
|
||||
],
|
||||
'baidu': [
|
||||
{'key': 'api_key', 'label': 'API Key', 'type': 'password', 'required': True},
|
||||
{'key': 'intelligent_url', 'label': '智能检索接口', 'type': 'text', 'required': False,
|
||||
'default': 'https://qianfan.baidubce.com/v2/ai_search/chat/completions'},
|
||||
{'key': 'web_search_url', 'label': '网页搜索接口', 'type': 'text', 'required': False,
|
||||
'default': 'https://qianfan.baidubce.com/v2/ai_search/web_search'},
|
||||
{'key': 'vdb_access_key', 'label': 'VDB Access Key', 'type': 'text', 'required': False},
|
||||
{'key': 'vdb_secret_key', 'label': 'VDB Secret Key', 'type': 'password', 'required': False},
|
||||
],
|
||||
'searxng': [
|
||||
{'key': 'base_url', 'label': 'SearXNG 地址', 'type': 'text', 'required': False,
|
||||
'default': 'http://localhost:8888'},
|
||||
],
|
||||
'baidu-intelligent': [], # 共享 baidu 配置,无需单独字段
|
||||
'duckduckgo': [],
|
||||
}
|
||||
return schemas.get(name, [])
|
||||
|
||||
|
||||
def mask_key(key: str) -> str:
|
||||
"""脱敏显示密钥"""
|
||||
if not key:
|
||||
return ''
|
||||
if len(key) <= 8:
|
||||
return key[:2] + '****'
|
||||
return key[:6] + '****' + key[-4:]
|
||||
118
hub/quota.py
Normal file
118
hub/quota.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""用量查询 — Tavily 剩余额度 + 百度 VDB 免费额度"""
|
||||
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
|
||||
def check_tavily_usage(api_key: str) -> dict:
|
||||
if not api_key:
|
||||
return {'error': '未配置 API Key', 'available': False}
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
'https://api.tavily.com/usage',
|
||||
headers={'Authorization': f'Bearer {api_key}'},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
return {'error': '查询过于频繁(10分钟内限10次)', 'available': False}
|
||||
if resp.status_code != 200:
|
||||
return {'error': f'API 返回 {resp.status_code}', 'available': False}
|
||||
|
||||
data = resp.json()
|
||||
key_data = data.get('key', {})
|
||||
acct = data.get('account', {})
|
||||
|
||||
usage = key_data.get('usage', 0) or 0
|
||||
limit = key_data.get('limit', 0) or 0
|
||||
remaining = max(limit - usage, 0) if limit else 0
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'key': {'usage': usage, 'limit': limit, 'remaining': remaining},
|
||||
'account': {
|
||||
'plan_limit': acct.get('plan_limit', 0) or 0,
|
||||
'search_usage': acct.get('search_usage', 0) or 0,
|
||||
'paygo_usage': acct.get('paygo_usage', 0) or 0,
|
||||
},
|
||||
}
|
||||
except requests.exceptions.Timeout:
|
||||
return {'error': '请求超时', 'available': False}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {'error': f'网络错误: {e}', 'available': False}
|
||||
|
||||
|
||||
def _bce_sign(access_key: str, secret_key: str, method: str, path: str,
|
||||
headers: dict, params: dict = None) -> str:
|
||||
timestamp = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
expiration = 1800
|
||||
|
||||
auth_string = f'bce-auth-v1/{access_key}/{timestamp}/{expiration}'
|
||||
signing_key = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
auth_string.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
signed_headers = sorted(headers.keys(), key=lambda k: k.lower())
|
||||
canonical_headers = ''.join(
|
||||
f'{k.lower()}:{headers[k].strip()}\n' for k in signed_headers
|
||||
)
|
||||
canonical_uri = path
|
||||
|
||||
if params:
|
||||
canonical_query = '&'.join(
|
||||
f'{k}={v}' for k, v in sorted(params.items())
|
||||
)
|
||||
else:
|
||||
canonical_query = ''
|
||||
|
||||
canonical_request = f'{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}'
|
||||
|
||||
signature = hmac.new(
|
||||
signing_key.encode('utf-8'),
|
||||
canonical_request.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return (f'bce-auth-v1/{access_key}/{timestamp}/{expiration}/'
|
||||
f'{";".join(k.lower() for k in signed_headers)}/{signature}')
|
||||
|
||||
|
||||
def check_baidu_vdb_quota(access_key: str, secret_key: str) -> dict:
|
||||
if not access_key or not secret_key:
|
||||
return {'error': '未配置 VDB Access Key / Secret Key', 'available': False}
|
||||
|
||||
host = 'vdb.bj.baidubce.com'
|
||||
path = '/v1/vdb/instance/freeQuota'
|
||||
method = 'GET'
|
||||
|
||||
headers = {
|
||||
'host': host,
|
||||
}
|
||||
auth = _bce_sign(access_key, secret_key, method, path, headers)
|
||||
headers['Authorization'] = auth
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f'https://{host}{path}',
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {'error': f'API 返回 {resp.status_code}', 'available': False}
|
||||
|
||||
data = resp.json()
|
||||
free_quota = data.get('freeQuota', 0)
|
||||
return {
|
||||
'available': True,
|
||||
'freeQuota': free_quota,
|
||||
}
|
||||
except requests.exceptions.Timeout:
|
||||
return {'error': '请求超时', 'available': False}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {'error': f'网络错误: {e}', 'available': False}
|
||||
112
hub/router.py
Normal file
112
hub/router.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""搜索路由器 — 多源路由 + 结果去重合并"""
|
||||
|
||||
import time
|
||||
from providers.base import SearchResult
|
||||
|
||||
|
||||
class SearchRouter:
|
||||
"""搜索路由器"""
|
||||
|
||||
def __init__(self, providers: dict):
|
||||
"""
|
||||
providers: {name: provider_instance}
|
||||
"""
|
||||
self.providers = providers
|
||||
|
||||
def search(self, query: str, source='auto', max_results=10):
|
||||
"""
|
||||
统一搜索入口
|
||||
|
||||
source 取值:
|
||||
- 'auto' : 自动选择最优可用源
|
||||
- 'tavily' : 指定单个源
|
||||
- 'tavily,baidu' : 多源合并
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
if not source or source == 'auto':
|
||||
results = self._auto_search(query, max_results)
|
||||
used_source = results[0].source if results else None
|
||||
elif ',' in source:
|
||||
sources = [s.strip() for s in source.split(',') if s.strip()]
|
||||
results = self._multi_search(query, sources, max_results)
|
||||
used_source = source
|
||||
else:
|
||||
provider = self.providers.get(source)
|
||||
if not provider or not provider.is_available():
|
||||
return {
|
||||
'query': query,
|
||||
'results': [],
|
||||
'total': 0,
|
||||
'source': source,
|
||||
'elapsed': round(time.time() - start, 2),
|
||||
'error': f'搜索源 "{source}" 不可用',
|
||||
}
|
||||
raw = provider.search(query, max_results)
|
||||
results = raw
|
||||
used_source = source
|
||||
|
||||
elapsed = round(time.time() - start, 2)
|
||||
return {
|
||||
'query': query,
|
||||
'results': [r.to_dict() for r in results],
|
||||
'total': len(results),
|
||||
'source': used_source,
|
||||
'elapsed': elapsed,
|
||||
}
|
||||
|
||||
def _auto_search(self, query, max_results):
|
||||
"""自动选择:按优先级 fallback,成功即返回"""
|
||||
sorted_providers = sorted(
|
||||
[p for p in self.providers.values() if p.is_available() and p.enabled],
|
||||
key=lambda p: p.priority,
|
||||
)
|
||||
|
||||
for provider in sorted_providers:
|
||||
try:
|
||||
results = provider.search(query, max_results)
|
||||
if results:
|
||||
return self._dedup(results, max_results)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return []
|
||||
|
||||
def _multi_search(self, query, sources, max_results):
|
||||
"""多源并发搜索"""
|
||||
all_results = []
|
||||
for name in sources:
|
||||
provider = self.providers.get(name)
|
||||
if not provider or not provider.is_available():
|
||||
continue
|
||||
try:
|
||||
# 每个源搜 max_results 条
|
||||
per_source = max(max_results // len(sources), 3)
|
||||
results = provider.search(query, per_source)
|
||||
all_results.extend(results)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return self._dedup(all_results, max_results)
|
||||
|
||||
def get_sources(self):
|
||||
"""返回所有搜索源状态"""
|
||||
return [
|
||||
p.get_status()
|
||||
for p in sorted(self.providers.values(), key=lambda p: p.priority)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _dedup(results, max_results):
|
||||
"""URL 去重 + 按分数排序"""
|
||||
seen = set()
|
||||
unique = []
|
||||
for r in results:
|
||||
key = r.url or r.title
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
|
||||
# 按分数降序排列
|
||||
unique.sort(key=lambda r: r.score, reverse=True)
|
||||
return unique[:max_results]
|
||||
0
providers/__init__.py
Normal file
0
providers/__init__.py
Normal file
137
providers/ai_provider.py
Normal file
137
providers/ai_provider.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""AI 总结服务 — 基于 OpenCodeZen / OpenAI 兼容 API"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from providers.base import BaseProvider
|
||||
|
||||
|
||||
def build_prompts(query, results):
|
||||
search_text = ''
|
||||
for i, r in enumerate(results, 1):
|
||||
search_text += f"[{i}] 标题: {r.get('title', '')}\n"
|
||||
search_text += f" 来源: {r.get('url', '')}\n"
|
||||
search_text += f" 内容: {r.get('content', '')}\n\n"
|
||||
|
||||
system_prompt = (
|
||||
'你是一个专业的搜索结果分析助手。'
|
||||
'请根据用户搜索词和搜索结果,生成一份结构化的中文总结报告。'
|
||||
'要求:\n'
|
||||
'1. 先概括本次搜索的核心主题\n'
|
||||
'2. 列出关键发现和重要信息点(分点说明)\n'
|
||||
'3. 指出不同信息来源之间的共识与分歧(如有)\n'
|
||||
'4. 给出综合结论\n'
|
||||
'使用简洁的 Markdown 格式,不要过于冗长。'
|
||||
)
|
||||
user_prompt = f'## 搜索词\n{query}\n\n## 搜索结果\n{search_text}\n\n请根据以上搜索结果生成总结报告。'
|
||||
return system_prompt, user_prompt
|
||||
|
||||
|
||||
class AIProvider(BaseProvider):
|
||||
name = 'ai'
|
||||
display_name = 'AI 总结'
|
||||
needs_api_key = True
|
||||
enabled = False
|
||||
priority = 50
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
oc = config.get('opencodezen', {})
|
||||
self.api_key = oc.get('api_key')
|
||||
self.base_url = oc.get('base_url', 'https://opencode.ai/zen/go/v1').rstrip('/')
|
||||
self.model = oc.get('model', 'deepseek-v4-flash')
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, query: str, max_results: int = 5) -> list:
|
||||
return []
|
||||
|
||||
def summarize(self, query: str, results: list) -> dict:
|
||||
if not self.is_available():
|
||||
return {'error': 'AI 总结未配置', 'summary': ''}
|
||||
|
||||
system_prompt, user_prompt = build_prompts(query, results)
|
||||
url = f'{self.base_url}/chat/completions'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_prompt},
|
||||
],
|
||||
'max_tokens': 4096,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=60)
|
||||
elapsed = round(time.time() - start, 2)
|
||||
if resp.status_code != 200:
|
||||
return {'error': f'AI API 返回 {resp.status_code}', 'summary': '', 'elapsed': elapsed}
|
||||
data = resp.json()
|
||||
return {
|
||||
'summary': data['choices'][0]['message']['content'],
|
||||
'model': self.model,
|
||||
'elapsed': elapsed,
|
||||
'usage': data.get('usage', {}),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': str(e), 'summary': '', 'elapsed': round(time.time() - start, 2)}
|
||||
|
||||
def summarize_stream(self, query: str, results: list):
|
||||
if not self.is_available():
|
||||
yield f"event: error\ndata: {json.dumps({'error': 'AI 总结未配置'})}\n\n"
|
||||
return
|
||||
|
||||
system_prompt, user_prompt = build_prompts(query, results)
|
||||
url = f'{self.base_url}/chat/completions'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_prompt},
|
||||
],
|
||||
'max_tokens': 4096,
|
||||
'temperature': 0.3,
|
||||
'stream': True,
|
||||
}
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120)
|
||||
if resp.status_code != 200:
|
||||
yield f"event: error\ndata: {json.dumps({'error': f'AI API 返回 {resp.status_code}'})}\n\n"
|
||||
return
|
||||
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line_str = line.decode('utf-8', errors='replace')
|
||||
if not line_str.startswith('data: '):
|
||||
continue
|
||||
data_str = line_str[6:]
|
||||
if data_str.strip() == '[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
delta = chunk.get('choices', [{}])[0].get('delta', {})
|
||||
content = delta.get('content', '')
|
||||
if content:
|
||||
yield f"event: delta\ndata: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
elapsed = round(time.time() - start, 2)
|
||||
yield f"event: meta\ndata: {json.dumps({'model': self.model, 'elapsed': elapsed})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
|
||||
168
providers/baidu_provider.py
Normal file
168
providers/baidu_provider.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""百度搜索源 — 通过百度千帆官方 API"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from providers.base import BaseProvider, SearchResult
|
||||
|
||||
|
||||
class BaiduProvider(BaseProvider):
|
||||
name = 'baidu'
|
||||
display_name = '百度搜索'
|
||||
needs_api_key = True
|
||||
enabled = True
|
||||
priority = 10 # auto 模式首选
|
||||
|
||||
def __init__(self, config: dict, mode='web'):
|
||||
"""
|
||||
mode: 'web' → 网页搜索(快速)
|
||||
'intelligent' → 智能检索生成(AI 分析)
|
||||
"""
|
||||
super().__init__(config)
|
||||
self._mode = mode
|
||||
if mode == 'intelligent':
|
||||
self.name = 'baidu-intelligent'
|
||||
self.display_name = '百度智能检索'
|
||||
self.priority = 21
|
||||
self.enabled = False # 仅手动选择,不参与 auto
|
||||
|
||||
bc = config.get('baidu', {})
|
||||
self.api_key = bc.get('api_key')
|
||||
self.intelligent_url = bc.get(
|
||||
'intelligent_url',
|
||||
'https://qianfan.baidubce.com/v2/ai_search/chat/completions',
|
||||
)
|
||||
self.web_search_url = bc.get(
|
||||
'web_search_url',
|
||||
'https://qianfan.baidubce.com/v2/ai_search/web_search',
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, query: str, max_results: int = 10) -> list:
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
if self._mode == 'intelligent':
|
||||
# 智能检索按引用条数扣费,限制最多3条省额度
|
||||
return self._intelligent_search(query, min(max_results, 3))
|
||||
return self._web_search(query, max_results)
|
||||
|
||||
def _intelligent_search(self, query: str, max_results: int) -> list:
|
||||
"""智能检索生成 — 返回 AI 回答 + 引用来源"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
payload = {
|
||||
'messages': [{'content': query, 'role': 'user'}],
|
||||
'stream': False,
|
||||
'model': 'ernie-4.5-turbo-128k',
|
||||
'enable_corner_markers': True,
|
||||
'enable_deep_search': True,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.intelligent_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
results = []
|
||||
|
||||
# 从引用来源中提取搜索结果
|
||||
references = data.get('references', []) or data.get('result', {}).get('references', [])
|
||||
for ref in references[:max_results]:
|
||||
title = ref.get('title', '') or ref.get('name', '')
|
||||
url = ref.get('url', '') or ref.get('link', '')
|
||||
content = ref.get('summary', '') or ref.get('content', '') or ref.get('snippet', '')
|
||||
if title and url:
|
||||
results.append(SearchResult(
|
||||
title=title,
|
||||
url=url,
|
||||
content=content,
|
||||
score=0.8,
|
||||
source=self.name,
|
||||
))
|
||||
|
||||
# 如果没有引用链接,尝试从 AI 回答的 content 中提取
|
||||
if not results:
|
||||
ai_content = ''
|
||||
try:
|
||||
ai_content = data['choices'][0]['message']['content']
|
||||
except (KeyError, IndexError):
|
||||
ai_content = data.get('result', {}).get('answer', '')
|
||||
|
||||
if ai_content:
|
||||
# 作为 AI 搜索结果展示
|
||||
results.append(SearchResult(
|
||||
title=f'百度AI: {query}',
|
||||
url=f'https://www.baidu.com/s?wd={query}',
|
||||
content=ai_content[:500],
|
||||
score=0.7,
|
||||
source=self.name,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return []
|
||||
|
||||
def _web_search(self, query: str, max_results: int) -> list:
|
||||
"""百度网页搜索 API"""
|
||||
if max_results <= 0:
|
||||
return []
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
payload = {
|
||||
'messages': [{'content': query, 'role': 'user'}],
|
||||
'search_source': 'baidu_search_v2',
|
||||
'resource_type_filter': [{'type': 'web', 'top_k': max_results}],
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.web_search_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=25,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
results = []
|
||||
|
||||
# 响应格式: {"request_id":"...", "references":[...]}
|
||||
refs = data.get('references', []) or data.get('result', {}).get('items', [])
|
||||
|
||||
for ref in refs[:max_results]:
|
||||
title = ref.get('title', '') or ref.get('name', '')
|
||||
url = ref.get('url', '') or ref.get('link', '')
|
||||
# snippet 是简短摘要,content 是完整内容
|
||||
snippet = ref.get('snippet', '') or ref.get('content', '') or ''
|
||||
published = ref.get('date', '') or ref.get('published_date', '')
|
||||
|
||||
if title and url:
|
||||
results.append(SearchResult(
|
||||
title=title,
|
||||
url=url,
|
||||
content=snippet[:500] if len(snippet) > 500 else snippet,
|
||||
score=0.6,
|
||||
source=self.name,
|
||||
published_date=published,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return []
|
||||
63
providers/base.py
Normal file
63
providers/base.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""搜索源抽象基类 — 所有搜索源统一接口"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class SearchResult:
|
||||
"""统一搜索结果格式"""
|
||||
def __init__(self, title='', url='', content='', score=0.0, source='',
|
||||
published_date=''):
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.content = content
|
||||
self.score = score
|
||||
self.source = source
|
||||
self.published_date = published_date
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'title': self.title,
|
||||
'url': self.url,
|
||||
'content': self.content,
|
||||
'score': self.score,
|
||||
'source': self.source,
|
||||
'published_date': self.published_date or '',
|
||||
}
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""搜索源基类"""
|
||||
|
||||
# 源名称(唯一标识)
|
||||
name = ''
|
||||
# 展示名称
|
||||
display_name = ''
|
||||
# 是否需要 API key
|
||||
needs_api_key = False
|
||||
# 是否默认启用
|
||||
enabled = False
|
||||
# 优先级(数字越小越优先)
|
||||
priority = 100
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query: str, max_results: int = 10) -> list:
|
||||
"""执行搜索,返回 SearchResult 列表"""
|
||||
...
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查当前源是否可用"""
|
||||
return True
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""返回源状态信息"""
|
||||
return {
|
||||
'name': self.name,
|
||||
'display_name': self.display_name,
|
||||
'available': self.is_available(),
|
||||
'enabled': self.enabled,
|
||||
'needs_api_key': self.needs_api_key,
|
||||
'priority': self.priority,
|
||||
}
|
||||
142
providers/duckduckgo_provider.py
Normal file
142
providers/duckduckgo_provider.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""DuckDuckGo 搜索源 — 免费,无需 API key"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from providers.base import BaseProvider, SearchResult
|
||||
|
||||
|
||||
class DuckDuckGoProvider(BaseProvider):
|
||||
name = 'duckduckgo'
|
||||
display_name = 'DuckDuckGo'
|
||||
needs_api_key = False
|
||||
enabled = False # 国内网络不可用,默认关闭
|
||||
priority = 30
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
|
||||
def search(self, query: str, max_results: int = 10) -> list:
|
||||
results = []
|
||||
|
||||
# 1. 先尝试 Instant Answer API(获取摘要和主题)
|
||||
try:
|
||||
url = 'https://api.duckduckgo.com/'
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'no_html': 1,
|
||||
'skip_disambig': 1,
|
||||
}
|
||||
resp = requests.get(url, params=params, timeout=8)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Abstract
|
||||
abstract = data.get('AbstractText', '')
|
||||
if abstract and data.get('AbstractURL'):
|
||||
results.append(SearchResult(
|
||||
title=data.get('Heading', 'DuckDuckGo 摘要'),
|
||||
url=data['AbstractURL'],
|
||||
content=abstract,
|
||||
score=0.9,
|
||||
source=self.name,
|
||||
))
|
||||
# Related topics
|
||||
for topic in data.get('RelatedTopics', []):
|
||||
if 'Topics' in topic:
|
||||
for sub in topic['Topics'][:3]:
|
||||
if sub.get('Text'):
|
||||
results.append(SearchResult(
|
||||
title=sub.get('Text', '')[:80],
|
||||
url=sub.get('FirstURL', ''),
|
||||
content=sub.get('Text', ''),
|
||||
score=0.7,
|
||||
source=self.name,
|
||||
))
|
||||
elif topic.get('Text'):
|
||||
results.append(SearchResult(
|
||||
title=topic.get('Text', '')[:80],
|
||||
url=topic.get('FirstURL', ''),
|
||||
content=topic.get('Text', ''),
|
||||
score=0.7,
|
||||
source=self.name,
|
||||
))
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
# 2. 如果结果不够,再抓取 HTML 版本获取更多结果
|
||||
if len(results) < max_results:
|
||||
try:
|
||||
url = 'https://html.duckduckgo.com/html/'
|
||||
resp = requests.post(url, data={'q': query}, timeout=15,
|
||||
headers={'User-Agent': 'Mozilla/5.0'})
|
||||
if resp.status_code == 200:
|
||||
html = resp.text
|
||||
more = self._parse_html_results(html, max_results - len(results))
|
||||
results.extend(more)
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
# 去重
|
||||
seen_urls = set()
|
||||
unique = []
|
||||
for r in results:
|
||||
if r.url and r.url not in seen_urls:
|
||||
seen_urls.add(r.url)
|
||||
unique.append(r)
|
||||
|
||||
return unique[:max_results]
|
||||
|
||||
def _parse_html_results(self, html: str, limit: int) -> list:
|
||||
"""简单解析 DuckDuckGo HTML 搜索结果"""
|
||||
results = []
|
||||
# 按 <a rel="nofollow" 分割找链接
|
||||
for block in html.split('<a rel="nofollow"')[1:]:
|
||||
if len(results) >= limit:
|
||||
break
|
||||
try:
|
||||
# 提取 URL
|
||||
href_start = block.find('href="')
|
||||
if href_start == -1:
|
||||
continue
|
||||
href_start += 6
|
||||
href_end = block.find('"', href_start)
|
||||
url = block[href_start:href_end]
|
||||
|
||||
# 提取标题 (在 <a 标签后找 >xxx</a>)
|
||||
title_start = block.find('>', href_end)
|
||||
if title_start == -1:
|
||||
continue
|
||||
title_start += 1
|
||||
title_end = block.find('</a>', title_start)
|
||||
title = self._clean_text(block[title_start:title_end])
|
||||
|
||||
# 提取摘要(在 <a> 后的某个 <td> 或 <div> 中)
|
||||
snippet = ''
|
||||
for kw in ['class="result-snippet"', 'class="snippet"']:
|
||||
idx = block.find(kw)
|
||||
if idx != -1:
|
||||
tag_close = block.find('>', idx) + 1
|
||||
next_tag = block.find('<', tag_close)
|
||||
if next_tag != -1:
|
||||
snippet = self._clean_text(block[tag_close:next_tag])
|
||||
break
|
||||
|
||||
if url and title:
|
||||
results.append(SearchResult(
|
||||
title=title,
|
||||
url=url,
|
||||
content=snippet or title,
|
||||
score=0.6,
|
||||
source=self.name,
|
||||
))
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(text: str) -> str:
|
||||
"""清理 HTML 标签和多余空白"""
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
61
providers/searxng_provider.py
Normal file
61
providers/searxng_provider.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""SearXNG 搜索源 — 自托管元搜索引擎"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from providers.base import BaseProvider, SearchResult
|
||||
|
||||
|
||||
class SearXNGProvider(BaseProvider):
|
||||
name = 'searxng'
|
||||
display_name = 'SearXNG'
|
||||
needs_api_key = False
|
||||
enabled = True
|
||||
priority = 30
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
sc = config.get('searxng', {})
|
||||
self.base_url = (sc.get('base_url') or 'http://localhost:8888').rstrip('/')
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def search(self, query: str, max_results: int = 10) -> list:
|
||||
url = f'{self.base_url}/search'
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'language': 'zh-CN',
|
||||
'categories': 'general',
|
||||
'pageno': 1,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=15,
|
||||
headers={'User-Agent': 'SearchHub/1.0',
|
||||
'Accept': 'application/json'})
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
results = []
|
||||
for item in data.get('results', []):
|
||||
published = item.get('publishedDate', '')
|
||||
if published:
|
||||
try:
|
||||
published = published.replace('T', ' ').split('+')[0].split('Z')[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
results.append(SearchResult(
|
||||
title=item.get('title', ''),
|
||||
url=item.get('url', ''),
|
||||
content=item.get('content', ''),
|
||||
score=item.get('score', 0.5),
|
||||
source=self.name,
|
||||
published_date=published,
|
||||
))
|
||||
return results[:max_results]
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return []
|
||||
59
providers/tavily_provider.py
Normal file
59
providers/tavily_provider.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Tavily 搜索源"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from providers.base import BaseProvider, SearchResult
|
||||
|
||||
|
||||
class TavilyProvider(BaseProvider):
|
||||
name = 'tavily'
|
||||
display_name = 'Tavily'
|
||||
needs_api_key = True
|
||||
enabled = True
|
||||
priority = 20
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
tc = config.get('tavily', {})
|
||||
self.api_key = tc.get('api_key')
|
||||
self.base_url = tc.get('base_url', 'https://api.tavily.com').rstrip('/')
|
||||
self.depth = tc.get('depth', 'basic')
|
||||
self.max_results = tc.get('max_results', 10)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, query: str, max_results: int = None) -> list:
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
url = f'{self.base_url}/search'
|
||||
payload = {
|
||||
'api_key': self.api_key,
|
||||
'query': query,
|
||||
'search_depth': self.depth,
|
||||
'max_results': max_results or self.max_results,
|
||||
'include_answer': False,
|
||||
'include_images': False,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
results = []
|
||||
for item in data.get('results', []):
|
||||
results.append(SearchResult(
|
||||
title=item.get('title', ''),
|
||||
url=item.get('url', ''),
|
||||
content=item.get('content', ''),
|
||||
score=item.get('score', 0),
|
||||
source=self.name,
|
||||
published_date=item.get('published_date', ''),
|
||||
))
|
||||
return results
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return []
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
pyyaml>=6.0
|
||||
72
search.py
Normal file
72
search.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unified search via local search-hub API (port 8650).
|
||||
Supports baidu, tavily, searxng, duckduckgo sources.
|
||||
|
||||
Usage:
|
||||
python3 search.py "<query>" [--source auto] [--limit 5]
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
SEARCH_API = "http://192.168.5.14:8650"
|
||||
|
||||
def search(query: str, source: str = "auto", max_results: int = 5) -> dict:
|
||||
payload = json.dumps({
|
||||
"query": query,
|
||||
"source": source,
|
||||
"max_results": max_results,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{SEARCH_API}/api/search",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.reason}", "results": []}
|
||||
except urllib.error.URLError as e:
|
||||
return {"error": f"Connection failed: {e.reason}", "results": []}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "results": []}
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(json.dumps({"error": "No query provided"}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
query = args[0]
|
||||
source = "auto"
|
||||
limit = 5
|
||||
for i, a in enumerate(args[1:]):
|
||||
if a == "--source" and i + 1 < len(args[1:]):
|
||||
source = args[1:][i + 1]
|
||||
elif a == "--limit" and i + 1 < len(args[1:]):
|
||||
limit = int(args[1:][i + 1])
|
||||
|
||||
result = search(query, source, limit)
|
||||
# Clean up for agent consumption
|
||||
output = {
|
||||
"query": result.get("query", query),
|
||||
"source": result.get("source", source),
|
||||
"elapsed": result.get("elapsed", 0),
|
||||
"total": result.get("total", len(result.get("results", []))),
|
||||
"results": [
|
||||
{
|
||||
"title": r.get("title", ""),
|
||||
"url": r.get("url", ""),
|
||||
"content": r.get("content", ""),
|
||||
"source": r.get("source", ""),
|
||||
}
|
||||
for r in result.get("results", [])[:limit]
|
||||
],
|
||||
}
|
||||
if "error" in result:
|
||||
output["error"] = result["error"]
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
6
static/css/bootstrap.min.css
vendored
Normal file
6
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
665
static/css/style.css
Normal file
665
static/css/style.css
Normal file
@@ -0,0 +1,665 @@
|
||||
/* Search Hub — Deepseek 风格 */
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--border: #e5e5e5;
|
||||
--accent: #4f6ef7;
|
||||
--accent-hover: #3b5de7;
|
||||
--accent-light: #eef1ff;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== 布局 ===== */
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ===== 顶部导航 ===== */
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.navbar-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 搜索区域 ===== */
|
||||
|
||||
.search-section {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
transition: margin 0.3s ease;
|
||||
}
|
||||
|
||||
.search-section.has-results {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-title.has-results {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 14px 20px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
border: none;
|
||||
padding: 14px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#search-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#search-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.summarize-btn-inline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.summarize-btn-inline:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.search-box button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== 搜索源选择 ===== */
|
||||
|
||||
.source-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.source-tag:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.source-active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
border-color: #eee;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.source-disabled:hover {
|
||||
color: #ccc;
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
/* ===== 状态条 ===== */
|
||||
|
||||
#status-bar {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ===== 搜索历史 ===== */
|
||||
|
||||
#history-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#history-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.history-tag:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 搜索结果 ===== */
|
||||
|
||||
#results-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-title a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.result-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
color: var(--success);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* ===== AI 总结 ===== */
|
||||
|
||||
#summary-section {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-content ul {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-content h1, .summary-content h2, .summary-content h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.summary-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.summary-input-placeholder {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 总结中动画 ===== */
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
30% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ===== 空 / 加载 ===== */
|
||||
|
||||
#empty-state, #loading {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 管理面板 ===== */
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-chip {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== API 文档 ===== */
|
||||
|
||||
.api-endpoint {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.api-endpoint:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-endpoint .method {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.method-get { color: var(--success); background: #ecfdf5; }
|
||||
.method-post { color: var(--accent); background: var(--accent-light); }
|
||||
|
||||
.api-endpoint .path {
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.api-endpoint .desc {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.api-endpoint .example {
|
||||
margin-top: 6px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: #f9f9f9;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-endpoint .example.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.show-example-btn {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.modal-box h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* ===== 表单 ===== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: var(--card-bg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== 按钮(纯文字/幽灵风格) ===== */
|
||||
|
||||
.btn-ghost {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-ghost.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost.danger:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== 按钮行 ===== */
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 0 16px; }
|
||||
.search-title { font-size: 28px; }
|
||||
.search-title.has-results { font-size: 18px; }
|
||||
.search-box input { padding: 12px 16px; font-size: 14px; }
|
||||
.search-box button { padding: 12px 20px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ===== 管理面板 — API 文档区域 ===== */
|
||||
#api-section .api-endpoint .path {
|
||||
word-break: break-all;
|
||||
}
|
||||
223
static/js/admin.js
Normal file
223
static/js/admin.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/* Search Hub — 管理面板 */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let currentEditSource = null;
|
||||
|
||||
loadSources();
|
||||
loadStatus();
|
||||
loadApiDocs();
|
||||
loadUsage();
|
||||
|
||||
// ===== 搜索源 =====
|
||||
function loadSources() {
|
||||
fetch('/api/sources')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var sources = data.sources || [];
|
||||
var html = '';
|
||||
sources.forEach(function (s) {
|
||||
var badge = s.available
|
||||
? '<span class="source-badge badge-ok">可用</span>'
|
||||
: '<span class="source-badge badge-fail">不可用</span>';
|
||||
var keyInfo = s.needs_api_key
|
||||
? (s.available ? ' | 密钥已配置' : ' | 需配置密钥')
|
||||
: ' | 无需密钥';
|
||||
var editBtn = s.needs_api_key
|
||||
? '<button class="btn-ghost" onclick="openEditModal(\'' + s.name + '\')">编辑</button>'
|
||||
: '';
|
||||
html += '<div class="source-card">';
|
||||
html += ' <div class="d-flex justify-content-between align-items-center">';
|
||||
html += ' <div><span class="source-name">' + escapeHtml(s.display_name || s.name) + '</span></div>';
|
||||
html += ' <div class="d-flex gap-3 align-items-center">' + badge + editBtn + '</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="small text-muted mt-1">' + s.name + keyInfo + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('sources-list').innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 服务状态 =====
|
||||
function loadStatus() {
|
||||
fetch('/api/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
document.getElementById('hub-status').textContent = data.status === 'ok' ? '运行中' : '异常';
|
||||
document.getElementById('hub-version').textContent = data.version || '-';
|
||||
document.getElementById('hub-history').textContent = data.history_count || 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== API 文档 =====
|
||||
function loadApiDocs() {
|
||||
fetch('/api/docs')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var endpoints = data.endpoints || [];
|
||||
var html = '';
|
||||
endpoints.forEach(function (ep) {
|
||||
var methodClass = ep.method === 'GET' ? 'method-get' : 'method-post';
|
||||
html += '<div class="api-endpoint">';
|
||||
html += ' <span class="method ' + methodClass + '">' + ep.method + '</span>';
|
||||
html += ' <span class="path">' + ep.path + '</span>';
|
||||
html += ' <div class="desc">' + escapeHtml(ep.desc) + '</div>';
|
||||
if (ep.example) {
|
||||
html += ' <button class="show-example-btn" onclick="toggleExample(this)">显示示例</button>';
|
||||
html += ' <div class="example">' + escapeHtml(ep.example) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('api-docs-list').innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 服务用量(异步加载,不阻塞页面) =====
|
||||
function loadUsage() {
|
||||
var el = document.getElementById('usage-list');
|
||||
fetch('/api/admin/usage')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '';
|
||||
|
||||
// Tavily(优先显示 account 级别用量)
|
||||
var t = data.tavily || {};
|
||||
if (t.available) {
|
||||
var usage = t.key && t.key.limit > 0 ? t.key.usage : (t.account ? t.account.search_usage : 0);
|
||||
var limit = t.key && t.key.limit > 0 ? t.key.limit : (t.account ? t.account.plan_limit : 0);
|
||||
var remaining = t.key && t.key.limit > 0 ? t.key.remaining : (limit - usage);
|
||||
var pct = limit > 0 ? Math.round(usage / limit * 100) : 0;
|
||||
html += '<div class="info-chip">';
|
||||
html += ' <strong>Tavily</strong>';
|
||||
html += ' <div style="margin-top:4px">已用: ' + usage + ' / ' + limit;
|
||||
html += ' <span style="margin-left:8px;color:' + (pct > 80 ? '#ef4444' : '#10b981') + '">剩余: ' + remaining + '</span></div>';
|
||||
if (t.account) {
|
||||
html += ' <div class="small text-muted" style="margin-top:2px">账户搜索: ' + t.account.search_usage + ' / ' + t.account.plan_limit;
|
||||
if (t.account.paygo_usage > 0) html += ' | 按量: ' + t.account.paygo_usage;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="info-chip"><strong>Tavily</strong><br><span class="text-muted">' + (t.error || '未配置') + '</span></div>';
|
||||
}
|
||||
|
||||
// 百度 VDB
|
||||
var b = data.baidu_vdb || {};
|
||||
if (b.available) {
|
||||
html += '<div class="info-chip" style="margin-top:6px">';
|
||||
html += ' <strong>百度 VDB</strong>';
|
||||
html += ' <div style="margin-top:4px">免费额度: ' + b.freeQuota + '</div>';
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="info-chip" style="margin-top:6px"><strong>百度 VDB</strong><br><span class="text-muted">' + (b.error || '未配置') + '</span></div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
el.innerHTML = '<div class="info-chip text-muted">用量查询暂不可用</div>';
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleExample = function (btn) {
|
||||
var example = btn.nextElementSibling;
|
||||
if (example) {
|
||||
example.classList.toggle('visible');
|
||||
btn.textContent = example.classList.contains('visible') ? '收起' : '显示示例';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 编辑弹窗 =====
|
||||
window.openEditModal = function (name) {
|
||||
currentEditSource = name;
|
||||
var modal = document.getElementById('edit-modal');
|
||||
var title = document.getElementById('edit-modal-title');
|
||||
var body = document.getElementById('edit-modal-body');
|
||||
title.textContent = '加载中...';
|
||||
body.innerHTML = '<div class="form-hint">加载中...</div>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
fetch('/api/admin/sources/' + name)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
title.textContent = '编辑: ' + (data.display_name || name);
|
||||
var html = '';
|
||||
if (data.fields.length === 0) {
|
||||
html = '<div class="form-hint">该搜索源无需配置</div>';
|
||||
} else {
|
||||
data.fields.forEach(function (f) {
|
||||
html += '<div class="form-group">';
|
||||
html += ' <label>' + escapeHtml(f.label) + (f.required ? ' <span class="text-danger">*</span>' : '') + '</label>';
|
||||
if (f.type === 'password') {
|
||||
html += ' <input type="password" class="form-control" id="cfg-' + f.key + '"';
|
||||
html += ' placeholder="' + (f.has_value ? '已配置,输入新值覆盖' : '请输入') + '">';
|
||||
if (f.has_value) {
|
||||
html += ' <div class="form-hint">当前: ' + escapeHtml(f.value) + '</div>';
|
||||
}
|
||||
} else {
|
||||
html += ' <input type="text" class="form-control" id="cfg-' + f.key + '"';
|
||||
html += ' value="' + escapeHtml(f.value || '') + '">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
body.innerHTML = html;
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '<div class="text-danger">加载失败: ' + err.message + '</div>';
|
||||
});
|
||||
};
|
||||
|
||||
window.closeEditModal = function () {
|
||||
document.getElementById('edit-modal').style.display = 'none';
|
||||
currentEditSource = null;
|
||||
};
|
||||
|
||||
window.saveConfig = function () {
|
||||
if (!currentEditSource) return;
|
||||
var body = document.getElementById('edit-modal-body');
|
||||
var inputs = body.querySelectorAll('input[id^="cfg-"]');
|
||||
var data = {};
|
||||
inputs.forEach(function (input) {
|
||||
var key = input.id.replace('cfg-', '');
|
||||
var val = input.value.trim();
|
||||
if (val) data[key] = val;
|
||||
});
|
||||
if (Object.keys(data).length === 0) {
|
||||
alert('请至少填写一个字段');
|
||||
return;
|
||||
}
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '保存中...';
|
||||
fetch('/api/admin/sources/' + currentEditSource, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
if (result.success) {
|
||||
closeEditModal();
|
||||
loadSources();
|
||||
loadStatus();
|
||||
} else {
|
||||
alert('保存失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
alert('请求失败: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 辅助 =====
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
363
static/js/app.js
Normal file
363
static/js/app.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/* Search Hub — Deepseek 风格 UI */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchBtn = document.getElementById('search-btn');
|
||||
const summarizeBtn = document.getElementById('summarize-btn');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const historyList = document.getElementById('history-list');
|
||||
const loading = document.getElementById('loading');
|
||||
const resultsSection = document.getElementById('results-section');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const summarySection = document.getElementById('summary-section');
|
||||
const summaryContent = document.getElementById('summary-content');
|
||||
const summaryMeta = document.getElementById('summary-meta');
|
||||
const searchSection = document.getElementById('search-section');
|
||||
const searchTitle = document.getElementById('search-title');
|
||||
|
||||
let currentResults = [];
|
||||
let currentQuery = '';
|
||||
let currentSource = 'auto';
|
||||
let isSummarizing = false;
|
||||
|
||||
// 初始隐藏总结区域
|
||||
summarySection.style.display = 'none';
|
||||
|
||||
loadHistory();
|
||||
setupSourceSelector();
|
||||
|
||||
searchBtn.addEventListener('click', doSearch);
|
||||
searchInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
summarizeBtn.addEventListener('click', doSummarize);
|
||||
|
||||
// ===== 搜索源选择 =====
|
||||
function setupSourceSelector() {
|
||||
document.querySelectorAll('.source-tag').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var source = el.dataset.source;
|
||||
if (!source) return;
|
||||
if (el.classList.contains('source-disabled')) return;
|
||||
document.querySelectorAll('.source-tag').forEach(function (t) {
|
||||
t.classList.remove('source-active');
|
||||
});
|
||||
el.classList.add('source-active');
|
||||
currentSource = source;
|
||||
if (searchInput.value.trim()) {
|
||||
doSearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 搜索(无总结) =====
|
||||
function doSearch() {
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
hideSummary();
|
||||
currentQuery = query;
|
||||
currentResults = [];
|
||||
showLoading(true);
|
||||
setStatus('');
|
||||
|
||||
searchSection.classList.add('has-results');
|
||||
searchTitle.textContent = query;
|
||||
|
||||
fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
source: currentSource,
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
showLoading(false);
|
||||
if (data.error) {
|
||||
setStatus(data.error);
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
currentResults = data.results || [];
|
||||
renderResults(currentResults, data);
|
||||
loadHistory();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showLoading(false);
|
||||
setStatus('请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 渲染结果 =====
|
||||
function renderResults(results, data) {
|
||||
if (!results || results.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
var sourceLabel = data.source || currentSource;
|
||||
var elapsed = data.elapsed || 0;
|
||||
setStatus('共找到 ' + results.length + ' 条结果(' + sourceLabel + ' | ' + elapsed + 's)');
|
||||
|
||||
document.querySelectorAll('.source-tag').forEach(function (t) {
|
||||
t.classList.remove('source-active');
|
||||
});
|
||||
var match = document.querySelector('.source-tag[data-source="' + sourceLabel + '"]');
|
||||
if (match) {
|
||||
match.classList.add('source-active');
|
||||
} else {
|
||||
var autoTag = document.querySelector('.source-tag[data-source="auto"]');
|
||||
if (autoTag) autoTag.classList.add('source-active');
|
||||
}
|
||||
|
||||
var html = '';
|
||||
results.forEach(function (item) {
|
||||
var dateStr = formatDate(item.published_date || '');
|
||||
var sourceTag = item.source ? '<span class="source-badge badge-ok">' + item.source + '</span>' : '';
|
||||
html += '<div class="result-card">';
|
||||
html += ' <div class="result-title"><a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener">' + escapeHtml(item.title) + '</a></div>';
|
||||
html += ' <div class="result-content">' + escapeHtml(item.content) + '</div>';
|
||||
html += ' <div class="result-meta">';
|
||||
html += ' <span class="result-url">' + escapeHtml(item.url) + '</span>';
|
||||
if (dateStr) html += ' <span>' + dateStr + '</span>';
|
||||
html += ' ' + sourceTag;
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
});
|
||||
resultsSection.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== AI 总结 =====
|
||||
function doSummarize() {
|
||||
if (isSummarizing) return;
|
||||
|
||||
// 无搜索词时取输入框内容
|
||||
if (!currentQuery) {
|
||||
var q = searchInput.value.trim();
|
||||
if (!q) return;
|
||||
currentQuery = q;
|
||||
currentSource = document.querySelector('.source-tag.source-active')?.dataset.source || 'auto';
|
||||
}
|
||||
|
||||
// 无搜索结果 → 先搜索再总结
|
||||
if (!currentResults || currentResults.length === 0) {
|
||||
doSearchThenSummarize();
|
||||
return;
|
||||
}
|
||||
|
||||
startSummarize();
|
||||
}
|
||||
|
||||
function doSearchThenSummarize() {
|
||||
var query = currentQuery || searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
hideSummary();
|
||||
currentQuery = query;
|
||||
currentResults = [];
|
||||
showLoading(true);
|
||||
setStatus('');
|
||||
|
||||
searchSection.classList.add('has-results');
|
||||
searchTitle.textContent = query;
|
||||
summarizeBtn.disabled = true;
|
||||
summarizeBtn.textContent = '搜索中...';
|
||||
|
||||
fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
source: currentSource,
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
showLoading(false);
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
if (data.error) {
|
||||
setStatus(data.error);
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
currentResults = data.results || [];
|
||||
renderResults(currentResults, data);
|
||||
loadHistory();
|
||||
if (currentResults.length > 0) {
|
||||
startSummarize();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
showLoading(false);
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
setStatus('请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function startSummarize() {
|
||||
if (!currentQuery || !currentResults || currentResults.length === 0) return;
|
||||
if (isSummarizing) return;
|
||||
isSummarizing = true;
|
||||
|
||||
summaryContent.innerHTML =
|
||||
'<div class="summary-input-placeholder" id="summary-placeholder">' +
|
||||
' <div class="typing-indicator"><span></span><span></span><span></span></div>' +
|
||||
' AI 总结中...' +
|
||||
'</div>';
|
||||
summaryMeta.textContent = '';
|
||||
summarySection.style.display = 'block';
|
||||
summarySection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
summarizeBtn.disabled = true;
|
||||
summarizeBtn.textContent = '总结中...';
|
||||
|
||||
var fullSummary = '';
|
||||
|
||||
fetch('/api/ai-summarize/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: currentQuery,
|
||||
results: currentResults,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || '请求失败'); });
|
||||
var reader = r.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
|
||||
function read() {
|
||||
return reader.read().then(function (result) {
|
||||
if (result.done) return;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
var parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
parts.forEach(function (part) {
|
||||
var lines = part.trim().split('\n');
|
||||
var eventType = '';
|
||||
var dataStr = '';
|
||||
lines.forEach(function (line) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) dataStr = line.slice(6).trim();
|
||||
});
|
||||
if (!dataStr) return;
|
||||
try {
|
||||
var parsed = JSON.parse(dataStr);
|
||||
if (eventType === 'delta') {
|
||||
fullSummary += parsed.content || '';
|
||||
summaryContent.innerHTML = markedSummary(fullSummary);
|
||||
} else if (eventType === 'meta') {
|
||||
summaryMeta.textContent = '模型: ' + (parsed.model || '') + ' | 耗时 ' + parsed.elapsed + 's';
|
||||
} else if (eventType === 'error') {
|
||||
setStatus('AI 总结失败: ' + (parsed.error || ''));
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
return read();
|
||||
});
|
||||
}
|
||||
return read();
|
||||
})
|
||||
.then(function () {
|
||||
isSummarizing = false;
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
})
|
||||
.catch(function (err) {
|
||||
isSummarizing = false;
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
summaryContent.innerHTML = '<div class="text-danger">总结失败: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function hideSummary() {
|
||||
summarySection.style.display = 'none';
|
||||
summaryContent.innerHTML = '';
|
||||
summaryMeta.textContent = '';
|
||||
isSummarizing = false;
|
||||
}
|
||||
|
||||
// ===== 搜索历史 =====
|
||||
function loadHistory() {
|
||||
fetch('/api/history')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var list = data.history || [];
|
||||
if (list.length === 0) {
|
||||
historyList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
list.forEach(function (item) {
|
||||
html += '<span class="history-tag">' + escapeHtml(item) + '</span>';
|
||||
});
|
||||
historyList.innerHTML = html;
|
||||
document.querySelectorAll('.history-tag').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
searchInput.value = el.textContent;
|
||||
doSearch();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 辅助 =====
|
||||
function showLoading(v) { loading.style.display = v ? 'block' : 'none'; }
|
||||
function setStatus(msg) { statusBar.textContent = msg; statusBar.style.display = msg ? 'block' : 'none'; }
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
} catch (e) { return dateStr; }
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function markedSummary(text) {
|
||||
if (!text) return '';
|
||||
var lines = text.split('\n');
|
||||
var html = '';
|
||||
var inList = false;
|
||||
lines.forEach(function (line) {
|
||||
line = escapeHtml(line);
|
||||
var hMatch = line.match(/^(#{1,3})\s+(.+)/);
|
||||
if (hMatch) {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
html += '<h' + hMatch[1].length + '>' + hMatch[2] + '</h' + hMatch[1].length + '>';
|
||||
return;
|
||||
}
|
||||
var lMatch = line.match(/^[\s]*[-*+]\s+(.+)/);
|
||||
if (lMatch) {
|
||||
if (!inList) { html += '<ul>'; inList = true; }
|
||||
html += '<li>' + lMatch[1] + '</li>';
|
||||
return;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
return;
|
||||
}
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
line = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html += '<p>' + line + '</p>';
|
||||
});
|
||||
if (inList) html += '</ul>';
|
||||
return html;
|
||||
}
|
||||
})();
|
||||
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
67
templates/admin.html
Normal file
67
templates/admin.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search Hub - 管理面板</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="navbar">
|
||||
<a href="/" class="navbar-brand">Search Hub</a>
|
||||
<div class="navbar-links">
|
||||
<a href="/web">返回搜索</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-layout">
|
||||
<!-- 左列: 服务状态 + 搜索源 -->
|
||||
<div>
|
||||
<!-- 服务状态 -->
|
||||
<div class="admin-section">
|
||||
<div class="admin-section-title">服务状态</div>
|
||||
<div class="info-chip">状态: <span id="hub-status">—</span></div>
|
||||
<div class="info-chip">版本: <span id="hub-version">—</span></div>
|
||||
<div class="info-chip">搜索历史: <span id="hub-history">—</span> 条</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务用量 -->
|
||||
<div class="admin-section">
|
||||
<div class="admin-section-title">服务用量</div>
|
||||
<div id="usage-list">
|
||||
<div class="info-chip">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索源列表 -->
|
||||
<div class="admin-section">
|
||||
<div class="admin-section-title">搜索源配置</div>
|
||||
<div id="sources-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右列: API 文档 -->
|
||||
<div class="admin-section" id="api-section">
|
||||
<div class="admin-section-title">API 调用方式</div>
|
||||
<div id="api-docs-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div id="edit-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h2 id="edit-modal-title">编辑配置</h2>
|
||||
<div id="edit-modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-ghost danger" onclick="closeEditModal()">取消</button>
|
||||
<button class="btn-primary" id="save-btn" onclick="saveConfig()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
templates/index.html
Normal file
83
templates/index.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
.initial-title { display: block; }
|
||||
.initial-title.has-results { display: none; }
|
||||
.compact-title { display: none; }
|
||||
.compact-title.has-results { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="navbar">
|
||||
<a href="/" class="navbar-brand">Search Hub</a>
|
||||
<div class="navbar-links">
|
||||
<a href="/admin">管理面板</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-section" id="search-section">
|
||||
<div class="search-title" id="search-title">Search Hub</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="search-input" placeholder="搜索..." autofocus>
|
||||
<button id="search-btn">搜索</button>
|
||||
<button id="summarize-btn" class="summarize-btn-inline">AI 总结</button>
|
||||
</div>
|
||||
<div class="source-bar">
|
||||
<span class="source-label">搜索源:</span>
|
||||
<span class="source-tag source-active" data-source="auto">自动</span>
|
||||
{% for s in sources if s.name not in ('ai',) %}
|
||||
<span class="source-tag {% if not s.available %}source-disabled{% endif %}"
|
||||
data-source="{{ s.name }}"
|
||||
{% if not s.available %}title="不可用"{% endif %}>
|
||||
{{ s.display_name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div id="history-section">
|
||||
<div id="history-list" class="d-flex flex-wrap gap-2 justify-content-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- 状态 -->
|
||||
<div id="status-bar"></div>
|
||||
|
||||
<!-- 加载中(初始隐藏) -->
|
||||
<div id="loading" style="display:none">搜索中<span class="typing-indicator"><span></span><span></span><span></span></span></div>
|
||||
|
||||
<!-- AI总结区域 — 在结果上方 -->
|
||||
<div id="summary-section">
|
||||
<div class="summary-card">
|
||||
<div class="summary-header">AI 综合总结</div>
|
||||
<div id="summary-content" class="summary-content">
|
||||
<div class="summary-input-placeholder" id="summary-placeholder">
|
||||
<div class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
AI 总结中...
|
||||
</div>
|
||||
</div>
|
||||
<div id="summary-meta" class="summary-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div id="results-section"></div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div id="empty-state">暂无搜索结果</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user