init: Search Hub - 统一多搜索引擎聚合服务

This commit is contained in:
2026-05-09 18:46:05 +08:00
commit 81d726179c
27 changed files with 3179 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
venv/
__pycache__/
*.pyc
.env
*.log

350
app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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. 添加字段 Schemahub/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
View File

82
hub/config_manager.py Normal file
View 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
View 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
View 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
View File

137
providers/ai_provider.py Normal file
View 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
View 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
View 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,
}

View 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

View 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 []

View 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
View File

@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.31
pyyaml>=6.0

72
search.py Normal file
View 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

File diff suppressed because one or more lines are too long

665
static/css/style.css Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
})();

363
static/js/app.js Normal file
View 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>' +
' &nbsp;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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
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

File diff suppressed because one or more lines are too long

67
templates/admin.html Normal file
View 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
View 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>
&nbsp;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>