diff --git a/README.md b/README.md index a3fc7fc..b886f80 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Article Writer - 文章编写系统 -基于 Flask + LLM 的智能文章编写系统,支持主题管理、素材收集、分类标签和大模型生成文章。 +基于 Flask + LLM 的智能文章编写系统,支持主题管理、素材收集、网络搜索、分类标签和大模型生成文章。 ## 功能 @@ -8,6 +8,7 @@ - 分类系统:多级分类,每个主题可归属多个分类 - 标签系统:灵活标签,每个主题支持多个标签,支持增删改 - 素材收集:支持文本素材和图片素材(含Ctrl+V粘贴截图) +- **网络搜索:集成 Tavily Search,在主题页面直接搜索网络资料,一键添加为素材** - Prompt编辑:每个主题可自定义生成提示词,支持历史记录 - 大模型生成:调用 LLM 根据素材自动生成文章 - 文章管理:查看、编辑、删除、复制、下载生成的文章 @@ -17,6 +18,7 @@ - 后端:Python 3 + Flask + SQLite - 前端:HTML/CSS/JS(Bootstrap 5 暗色主题) - LLM:兼容 OpenAI API 格式的大模型接口 +- 搜索:Tavily Search API ## 快速开始 @@ -37,6 +39,8 @@ python app.py ## 配置 +### LLM 配置 + 通过环境变量或页面配置 LLM 接口: | 环境变量 | 说明 | 默认值 | @@ -45,6 +49,25 @@ python app.py | LLM_API_KEY | API 密钥 | - | | LLM_MODEL | 模型名称 | qwen3.6-plus | +### 搜索配置 + +在首页右上角点击「搜索配置」按钮,配置 Tavily Search API Key。 + +获取 API Key: [tavily.com](https://tavily.com) + +支持的搜索参数: +- **搜索深度**:基础(basic) / 深度(advanced) +- **时间范围**:不限 / 最近一天 / 一周 / 一月 / 一年 + +## 网络搜索功能 + +在每个主题的详情页中,提供「网络搜索」面板: + +1. 输入关键词,选择搜索深度和时间范围 +2. 点击搜索,结果以列表形式展示(标题、URL、摘要、相关度) +3. 点击「查看详情」可获取网页完整内容 +4. 点击「添加为素材」可将搜索结果(含详细内容)一键添加为文本素材 + ## 数据结构 - **topics** - 文章主题 @@ -55,7 +78,9 @@ python app.py - **materials** - 素材(文本/图片) - **articles** - 生成的文章 - **llm_config** - LLM接口配置 +- **search_config** - 搜索接口配置 - **prompt_history** - Prompt历史记录 +- **prompt_templates** - Prompt模板 ## 项目结构 @@ -63,8 +88,8 @@ python app.py article-writer/ ├── app.py # 主应用(Flask后端) ├── templates/ -│ ├── index.html # 首页(主题列表 + 分类/标签筛选) -│ └── topic.html # 主题详情页(素材 + 文章生成 + 分类标签编辑) +│ ├── index.html # 首页(主题列表 + 分类/标签筛选 + 搜索配置) +│ └── topic.html # 主题详情页(素材 + 网络搜索 + 文章生成 + 分类标签编辑) ├── static/ │ └── uploads/ # 图片上传目录 ├── .env.example # 环境变量示例 @@ -73,6 +98,12 @@ article-writer/ ## API +### 搜索 +- `GET /api/search-config` - 获取搜索配置(API Key 脱敏) +- `PUT /api/search-config` - 更新搜索配置 +- `POST /api/search` - 执行网络搜索 +- `POST /api/search/detail` - 获取搜索结果详细内容 + ### 分类 - `GET /api/categories` - 分类列表(含主题计数) - `POST /api/categories` - 创建分类 diff --git a/app.py b/app.py index ce0b91b..3f32626 100644 --- a/app.py +++ b/app.py @@ -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']) diff --git a/templates/index.html b/templates/index.html index 661429d..2364e12 100644 --- a/templates/index.html +++ b/templates/index.html @@ -79,6 +79,9 @@ + @@ -325,6 +328,47 @@ + +