feat: 添加网络搜索功能(Tavily Search)

- 后端:新增 search_config 表存储搜索接口配置
- 后端:实现 /api/search 和 /api/search/detail API
- 前端:首页添加搜索配置入口(模态框)
- 前端:主题详情页添加搜索面板(搜索框+结果列表+详情查看)
- 搜索结果可一键添加为素材
- 支持搜索深度和时间范围筛选
- 更新 README 文档
This commit is contained in:
2026-05-23 23:22:30 +08:00
parent 8899b21aea
commit 6bf20062d2
4 changed files with 477 additions and 5 deletions

136
app.py
View File

@@ -117,6 +117,12 @@ def init_db():
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS search_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT DEFAULT 'tavily',
api_key TEXT DEFAULT '',
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
''')
# 初始化默认Prompt模板
default_templates = [
@@ -147,6 +153,9 @@ def init_db():
conn.execute('ALTER TABLE materials ADD COLUMN selected INTEGER DEFAULT 1')
except Exception:
pass
# 初始化默认搜索配置
conn.execute('INSERT OR IGNORE INTO search_config (id, provider, api_key) VALUES (1, ?, ?)',
('tavily', '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'))
conn.close()
@@ -724,6 +733,133 @@ def analyze_material(material_id):
return jsonify({'analysis': analysis_text})
# ==================== 搜索配置与搜索API ====================
def get_search_config():
"""从数据库读取搜索配置"""
conn = get_db()
row = conn.execute('SELECT * FROM search_config WHERE id=1').fetchone()
conn.close()
if row and row['api_key']:
return {'provider': row['provider'], 'api_key': row['api_key']}
return {'provider': 'tavily', 'api_key': ''}
@app.route('/api/search-config', methods=['GET'])
def get_search_config_api():
config = get_search_config()
key = config['api_key']
masked = key[:8] + '***' + key[-4:] if len(key) > 12 else ('***' if key else '')
return jsonify({
'provider': config['provider'],
'api_key_masked': masked,
'api_key_set': bool(key)
})
@app.route('/api/search-config', methods=['PUT'])
def update_search_config():
data = request.get_json(force=True)
conn = get_db()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
current = conn.execute('SELECT * FROM search_config WHERE id=1').fetchone()
provider = data.get('provider', current['provider'] if current else 'tavily')
api_key = data.get('api_key', '') or (current['api_key'] if current else '')
conn.execute('''
INSERT INTO search_config (id, provider, api_key, updated_at) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET provider=excluded.provider, api_key=excluded.api_key, updated_at=excluded.updated_at
''', (provider, api_key, now))
conn.commit()
conn.close()
return jsonify({'ok': True})
@app.route('/api/search', methods=['POST'])
def search_web():
"""调用 Tavily Search API 进行搜索"""
data = request.get_json(force=True)
query = data.get('query', '').strip()
if not query:
return jsonify({'error': '搜索关键词不能为空'}), 400
search_config = get_search_config()
if not search_config['api_key']:
return jsonify({'error': '请先配置搜索API Key首页右上角设置'}), 400
max_results = data.get('max_results', 8)
search_depth = data.get('search_depth', 'basic')
time_range = data.get('time_range', None)
try:
req_body = {
'query': query,
'max_results': max_results,
'search_depth': search_depth,
'include_raw_content': False,
}
if time_range:
req_body['time_range'] = time_range
resp = requests.post(
'https://api.tavily.com/search',
headers={
'Authorization': f"Bearer {search_config['api_key']}",
'Content-Type': 'application/json'
},
json=req_body,
timeout=30
)
resp.raise_for_status()
result = resp.json()
return jsonify(result)
except requests.exceptions.HTTPError as e:
return jsonify({'error': f'搜索请求失败: HTTP {e.response.status_code}'}), 502
except Exception as e:
return jsonify({'error': f'搜索失败: {str(e)}'}), 500
@app.route('/api/search/detail', methods=['POST'])
def search_detail():
"""调用 Tavily Search API 获取详细内容include_raw_content"""
data = request.get_json(force=True)
query = data.get('query', '').strip()
url = data.get('url', '').strip()
if not query:
return jsonify({'error': '搜索关键词不能为空'}), 400
search_config = get_search_config()
if not search_config['api_key']:
return jsonify({'error': '请先配置搜索API Key'}), 400
try:
req_body = {
'query': query,
'max_results': 3,
'search_depth': 'advanced',
'include_raw_content': True,
}
if url:
req_body['include_domains'] = [url.split('/')[2]] if '/' in url else [url]
resp = requests.post(
'https://api.tavily.com/search',
headers={
'Authorization': f"Bearer {search_config['api_key']}",
'Content-Type': 'application/json'
},
json=req_body,
timeout=45
)
resp.raise_for_status()
result = resp.json()
# 如果指定了URL过滤只返回该URL的结果
if url:
result['results'] = [r for r in result.get('results', []) if r.get('url') == url]
return jsonify(result)
except Exception as e:
return jsonify({'error': f'获取详情失败: {str(e)}'}), 500
# ==================== LLM配置API ====================
@app.route('/api/llm-config', methods=['GET'])