#!/usr/bin/env python3 """ 文章编写系统 - Article Writer System 功能:主题管理、素材收集、Prompt编辑、大模型生成文章、分类与标签 """ import os import json import uuid import time import sqlite3 import requests from datetime import datetime from flask import Flask, render_template, request, jsonify, redirect, url_for, send_from_directory from werkzeug.utils import secure_filename BASE_DIR = os.path.dirname(os.path.abspath(__file__)) UPLOAD_DIR = os.path.join(BASE_DIR, 'static', 'uploads') DB_PATH = os.path.join(BASE_DIR, 'articles.db') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'} # LLM配置(通过环境变量读取,避免密钥泄露;运行时可从数据库覆盖) LLM_URL = os.environ.get('LLM_URL', 'https://www.autodl.art/api/v1/chat/completions') LLM_API_KEY = os.environ.get('LLM_API_KEY', '') LLM_MODEL = os.environ.get('LLM_MODEL', 'qwen3.6-plus') app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn def init_db(): conn = get_db() conn.executescript(''' CREATE TABLE IF NOT EXISTS topics ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', prompt TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')), updated_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS materials ( id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', content TEXT DEFAULT '', file_path TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now','localtime')), FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS articles ( id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, content TEXT DEFAULT '', prompt_used TEXT DEFAULT '', model TEXT DEFAULT '', llm_url TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')), FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS llm_config ( id INTEGER PRIMARY KEY CHECK (id = 1), url TEXT DEFAULT '', api_key TEXT DEFAULT '', model TEXT DEFAULT '', updated_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS prompt_history ( id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, prompt TEXT NOT NULL DEFAULT '', model TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')), FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, created_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS topic_categories ( topic_id TEXT NOT NULL, category_id TEXT NOT NULL, PRIMARY KEY (topic_id, category_id), FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS topic_tags ( topic_id TEXT NOT NULL, tag_id TEXT NOT NULL, PRIMARY KEY (topic_id, tag_id), FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); ''') # 初始化默认配置 conn.execute('INSERT OR IGNORE INTO llm_config (id, url, api_key, model) VALUES (1, ?, ?, ?)', (LLM_URL, LLM_API_KEY, LLM_MODEL)) conn.commit() conn.close() def get_llm_config(): """从数据库读取LLM配置,如无则用环境变量默认值""" conn = get_db() row = conn.execute('SELECT * FROM llm_config WHERE id=1').fetchone() conn.close() if row and row['url']: return {'url': row['url'], 'api_key': row['api_key'], 'model': row['model'] or LLM_MODEL} return {'url': LLM_URL, 'api_key': LLM_API_KEY, 'model': LLM_MODEL} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def _topic_with_tags(conn, topic_row): """给 topic row 附上分类和标签信息""" t = dict(topic_row) cats = conn.execute( 'SELECT c.* FROM categories c JOIN topic_categories tc ON c.id=tc.category_id WHERE tc.topic_id=? ORDER BY c.sort_order', (t['id'],) ).fetchall() t['categories'] = [dict(c) for c in cats] tags = conn.execute( 'SELECT tg.* FROM tags tg JOIN topic_tags tt ON tg.id=tt.tag_id WHERE tt.topic_id=? ORDER BY tg.name', (t['id'],) ).fetchall() t['tags'] = [dict(tg) for tg in tags] return t # ==================== 页面路由 ==================== @app.route('/') def index(): conn = get_db() topics = conn.execute( 'SELECT t.*, (SELECT COUNT(*) FROM materials WHERE topic_id=t.id) as mat_count ' 'FROM topics t ORDER BY updated_at DESC' ).fetchall() categories = conn.execute('SELECT * FROM categories ORDER BY sort_order, name').fetchall() tags = conn.execute('SELECT * FROM tags ORDER BY name').fetchall() # 附上分类和标签 topic_list = [] for t in topics: topic_list.append(_topic_with_tags(conn, t)) conn.close() return render_template('index.html', topics=topic_list, categories=categories, tags=tags) @app.route('/topic/') def topic_detail(topic_id): conn = get_db() topic = conn.execute('SELECT * FROM topics WHERE id=?', (topic_id,)).fetchone() if not topic: conn.close() return redirect(url_for('index')) topic = _topic_with_tags(conn, topic) materials = conn.execute( 'SELECT * FROM materials WHERE topic_id=? ORDER BY sort_order, created_at', (topic_id,) ).fetchall() articles = conn.execute( 'SELECT * FROM articles WHERE topic_id=? ORDER BY created_at DESC', (topic_id,) ).fetchall() categories = conn.execute('SELECT * FROM categories ORDER BY sort_order, name').fetchall() tags = conn.execute('SELECT * FROM tags ORDER BY name').fetchall() conn.close() return render_template('topic.html', topic=topic, materials=materials, articles=articles, categories=categories, tags=tags) # ==================== 主题API ==================== @app.route('/api/topics', methods=['POST']) def create_topic(): data = request.get_json(force=True) name = data.get('name', '').strip() if not name: return jsonify({'error': '主题名不能为空'}), 400 conn = get_db() tid = str(uuid.uuid4())[:8] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn.execute( 'INSERT INTO topics (id, name, description, prompt, created_at, updated_at) VALUES (?,?,?,?,?,?)', (tid, name, data.get('description', ''), data.get('prompt', ''), now, now) ) # 关联分类 for cat_id in data.get('category_ids', []): conn.execute('INSERT OR IGNORE INTO topic_categories (topic_id, category_id) VALUES (?,?)', (tid, cat_id)) # 关联标签(如果传了已有tag id) for tag_id in data.get('tag_ids', []): conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (tid, tag_id)) # 处理新标签(按名称创建) for tag_name in data.get('new_tags', []): tag_name = tag_name.strip() if not tag_name: continue existing = conn.execute('SELECT id FROM tags WHERE name=?', (tag_name,)).fetchone() if existing: conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (tid, existing['id'])) else: tag_id = str(uuid.uuid4())[:8] conn.execute('INSERT INTO tags (id, name, created_at) VALUES (?,?,?)', (tag_id, tag_name, now)) conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (tid, tag_id)) conn.commit() topic = conn.execute('SELECT * FROM topics WHERE id=?', (tid,)).fetchone() result = _topic_with_tags(conn, topic) conn.close() return jsonify(result) @app.route('/api/topics/', methods=['PUT']) def update_topic(topic_id): data = request.get_json(force=True) conn = get_db() now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') fields = [] values = [] for key in ['name', 'description', 'prompt']: if key in data: fields.append(f'{key}=?') values.append(data[key]) if fields: fields.append('updated_at=?') values.append(now) values.append(topic_id) conn.execute(f'UPDATE topics SET {",".join(fields)} WHERE id=?', values) conn.commit() # 更新分类关联 if 'category_ids' in data: conn.execute('DELETE FROM topic_categories WHERE topic_id=?', (topic_id,)) for cat_id in data['category_ids']: conn.execute('INSERT OR IGNORE INTO topic_categories (topic_id, category_id) VALUES (?,?)', (topic_id, cat_id)) # 更新标签关联 if 'tag_ids' in data or 'new_tags' in data: conn.execute('DELETE FROM topic_tags WHERE topic_id=?', (topic_id,)) for tag_id in data.get('tag_ids', []): conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (topic_id, tag_id)) for tag_name in data.get('new_tags', []): tag_name = tag_name.strip() if not tag_name: continue existing = conn.execute('SELECT id FROM tags WHERE name=?', (tag_name,)).fetchone() if existing: conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (topic_id, existing['id'])) else: tag_id = str(uuid.uuid4())[:8] conn.execute('INSERT INTO tags (id, name, created_at) VALUES (?,?,?)', (tag_id, tag_name, now)) conn.execute('INSERT OR IGNORE INTO topic_tags (topic_id, tag_id) VALUES (?,?)', (topic_id, tag_id)) # 清理无引用的孤立标签 conn.execute('DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM topic_tags)') conn.commit() topic = conn.execute('SELECT * FROM topics WHERE id=?', (topic_id,)).fetchone() result = _topic_with_tags(conn, topic) conn.close() return jsonify(result) @app.route('/api/topics/', methods=['DELETE']) def delete_topic(topic_id): conn = get_db() # 删除关联的素材文件 materials = conn.execute('SELECT file_path FROM materials WHERE topic_id=? AND type="image"', (topic_id,)).fetchall() for m in materials: if m['file_path']: fp = os.path.join(BASE_DIR, 'static', m['file_path']) if os.path.exists(fp): os.remove(fp) conn.execute('DELETE FROM topics WHERE id=?', (topic_id,)) # 清理孤立标签 conn.execute('DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM topic_tags)') conn.commit() conn.close() return jsonify({'ok': True}) # ==================== 分类API ==================== @app.route('/api/categories', methods=['GET']) def list_categories(): conn = get_db() rows = conn.execute( 'SELECT c.*, (SELECT COUNT(*) FROM topic_categories WHERE category_id=c.id) as topic_count ' 'FROM categories c ORDER BY c.sort_order, c.name' ).fetchall() conn.close() return jsonify([dict(r) for r in rows]) @app.route('/api/categories', methods=['POST']) def create_category(): data = request.get_json(force=True) name = data.get('name', '').strip() if not name: return jsonify({'error': '分类名不能为空'}), 400 conn = get_db() existing = conn.execute('SELECT * FROM categories WHERE name=?', (name,)).fetchone() if existing: conn.close() return jsonify({'error': '分类已存在'}), 400 cid = str(uuid.uuid4())[:8] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn.execute('INSERT INTO categories (id, name, sort_order, created_at) VALUES (?,?,?,?)', (cid, name, data.get('sort_order', 0), now)) conn.commit() cat = conn.execute('SELECT * FROM categories WHERE id=?', (cid,)).fetchone() conn.close() return jsonify(dict(cat)) @app.route('/api/categories/', methods=['PUT']) def update_category(category_id): data = request.get_json(force=True) conn = get_db() fields = [] values = [] for key in ['name', 'sort_order']: if key in data: fields.append(f'{key}=?') values.append(data[key]) if fields: values.append(category_id) conn.execute(f'UPDATE categories SET {",".join(fields)} WHERE id=?', values) conn.commit() cat = conn.execute('SELECT * FROM categories WHERE id=?', (category_id,)).fetchone() conn.close() return jsonify(dict(cat)) if cat else jsonify({'error': '未找到'}), 404 @app.route('/api/categories/', methods=['DELETE']) def delete_category(category_id): conn = get_db() conn.execute('DELETE FROM categories WHERE id=?', (category_id,)) conn.commit() conn.close() return jsonify({'ok': True}) # ==================== 标签API ==================== @app.route('/api/tags', methods=['GET']) def list_tags(): conn = get_db() rows = conn.execute( 'SELECT tg.*, (SELECT COUNT(*) FROM topic_tags WHERE tag_id=tg.id) as topic_count ' 'FROM tags tg ORDER BY tg.name' ).fetchall() conn.close() return jsonify([dict(r) for r in rows]) @app.route('/api/tags', methods=['POST']) def create_tag(): data = request.get_json(force=True) name = data.get('name', '').strip() if not name: return jsonify({'error': '标签名不能为空'}), 400 conn = get_db() existing = conn.execute('SELECT * FROM tags WHERE name=?', (name,)).fetchone() if existing: conn.close() return jsonify(dict(existing)) # 已存在直接返回 tid = str(uuid.uuid4())[:8] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn.execute('INSERT INTO tags (id, name, created_at) VALUES (?,?,?)', (tid, name, now)) conn.commit() tag = conn.execute('SELECT * FROM tags WHERE id=?', (tid,)).fetchone() conn.close() return jsonify(dict(tag)) @app.route('/api/tags/', methods=['PUT']) def update_tag(tag_id): data = request.get_json(force=True) conn = get_db() tag = conn.execute('SELECT * FROM tags WHERE id=?', (tag_id,)).fetchone() if not tag: conn.close() return jsonify({'error': '标签不存在'}), 404 name = data.get('name', '').strip() if not name: conn.close() return jsonify({'error': '标签名不能为空'}), 400 # 检查重名(排除自身) existing = conn.execute('SELECT id FROM tags WHERE name=? AND id!=?', (name, tag_id)).fetchone() if existing: conn.close() return jsonify({'error': '标签名已存在'}), 400 conn.execute('UPDATE tags SET name=? WHERE id=?', (name, tag_id)) conn.commit() tag = conn.execute('SELECT * FROM tags WHERE id=?', (tag_id,)).fetchone() conn.close() return jsonify(dict(tag)) @app.route('/api/tags/', methods=['DELETE']) def delete_tag(tag_id): conn = get_db() conn.execute('DELETE FROM tags WHERE id=?', (tag_id,)) conn.commit() conn.close() return jsonify({'ok': True}) # ==================== 素材API ==================== @app.route('/api/topics//materials', methods=['POST']) def add_material(topic_id): conn = get_db() # 检查主题存在 if not conn.execute('SELECT id FROM topics WHERE id=?', (topic_id,)).fetchone(): conn.close() return jsonify({'error': '主题不存在'}), 404 mtype = request.form.get('type', 'text') mid = str(uuid.uuid4())[:8] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') if mtype == 'image': file = request.files.get('file') if not file or not allowed_file(file.filename): conn.close() return jsonify({'error': '不支持的图片格式'}), 400 ext = secure_filename(file.filename).rsplit('.', 1)[-1].lower() filename = f"{topic_id}_{mid}_{int(time.time())}.{ext}" filepath = os.path.join(UPLOAD_DIR, filename) file.save(filepath) conn.execute( 'INSERT INTO materials (id, topic_id, type, content, file_path, created_at) VALUES (?,?,?,?,?,?)', (mid, topic_id, 'image', request.form.get('content', ''), f'uploads/{filename}', now) ) else: content = request.form.get('content', '').strip() if not content: conn.close() return jsonify({'error': '文本内容不能为空'}), 400 conn.execute( 'INSERT INTO materials (id, topic_id, type, content, created_at) VALUES (?,?,?,?,?)', (mid, topic_id, 'text', content, now) ) conn.execute('UPDATE topics SET updated_at=? WHERE id=?', (now, topic_id)) conn.commit() material = conn.execute('SELECT * FROM materials WHERE id=?', (mid,)).fetchone() conn.close() return jsonify(dict(material)) @app.route('/api/materials/', methods=['PUT']) def update_material(material_id): data = request.get_json(force=True) conn = get_db() fields = [] values = [] for key in ['content', 'sort_order']: if key in data: fields.append(f'{key}=?') values.append(data[key]) if fields: values.append(material_id) conn.execute(f'UPDATE materials SET {",".join(fields)} WHERE id=?', values) conn.commit() material = conn.execute('SELECT * FROM materials WHERE id=?', (material_id,)).fetchone() conn.close() return jsonify(dict(material)) if material else jsonify({'error': '未找到'}), 404 @app.route('/api/materials/', methods=['DELETE']) def delete_material(material_id): conn = get_db() mat = conn.execute('SELECT * FROM materials WHERE id=?', (material_id,)).fetchone() if mat and mat['file_path']: fp = os.path.join(BASE_DIR, 'static', mat['file_path']) if os.path.exists(fp): os.remove(fp) conn.execute('DELETE FROM materials WHERE id=?', (material_id,)) conn.commit() conn.close() return jsonify({'ok': True}) # ==================== LLM配置API ==================== @app.route('/api/llm-config', methods=['GET']) def get_llm_config_api(): config = get_llm_config() # 隐藏部分API Key key = config['api_key'] masked = key[:8] + '***' + key[-4:] if len(key) > 12 else '***' return jsonify({ 'url': config['url'], 'api_key_masked': masked, 'api_key_set': bool(key), 'model': config['model'] }) @app.route('/api/llm-config', methods=['PUT']) def update_llm_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 llm_config WHERE id=1').fetchone() url = data.get('url', current['url'] if current else LLM_URL) # 如果前端传了空key则保留旧key api_key = data.get('api_key', '') or (current['api_key'] if current else LLM_API_KEY) model = data.get('model', current['model'] if current else LLM_MODEL) conn.execute(''' INSERT INTO llm_config (id, url, api_key, model, updated_at) VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET url=excluded.url, api_key=excluded.api_key, model=excluded.model, updated_at=excluded.updated_at ''', (url, api_key, model, now)) conn.commit() conn.close() return jsonify({'ok': True}) # ==================== Prompt历史API ==================== @app.route('/api/topics//prompt-history', methods=['GET']) def get_prompt_history(topic_id): conn = get_db() history = conn.execute( 'SELECT * FROM prompt_history WHERE topic_id=? ORDER BY created_at DESC LIMIT 20', (topic_id,) ).fetchall() conn.close() return jsonify([dict(h) for h in history]) # ==================== 文章生成API ==================== @app.route('/api/topics//generate', methods=['POST']) def generate_article(topic_id): conn = get_db() topic = conn.execute('SELECT * FROM topics WHERE id=?', (topic_id,)).fetchone() if not topic: conn.close() return jsonify({'error': '主题不存在'}), 404 materials = conn.execute( 'SELECT * FROM materials WHERE topic_id=? ORDER BY sort_order, created_at', (topic_id,) ).fetchall() conn.close() # 构建素材文本 material_parts = [] for m in materials: if m['type'] == 'text': material_parts.append(f"【文本素材】\n{m['content']}") elif m['type'] == 'image': desc = m['content'] or '(图片)' material_parts.append(f"【图片素材】{desc}") materials_text = '\n\n'.join(material_parts) if material_parts else '(暂无素材)' # 构建prompt custom_prompt = topic['prompt'] or '请根据以下素材撰写一篇结构清晰、内容丰富的文章:' # 获取LLM配置 llm_config = get_llm_config() llm_url = llm_config['url'] llm_key = llm_config['api_key'] llm_model = llm_config['model'] if not llm_key: return jsonify({'error': '请先配置LLM API Key(首页右上角设置)'}), 400 user_message = f"""{custom_prompt} 主题:{topic['name']} {f'主题说明:{topic["description"]}' if topic['description'] else ''} === 素材 === {materials_text} === 要求 === 1. 充分利用提供的素材 2. 文章结构完整,层次分明 3. 语言流畅,逻辑清晰 4. 不要输出思考过程,直接给出文章内容""" # 调用大模型 try: resp = requests.post( llm_url, headers={ 'Authorization': f'Bearer {llm_key}', 'Content-Type': 'application/json' }, json={ 'model': llm_model, 'messages': [ {'role': 'user', 'content': user_message} ], 'temperature': 0.7, 'max_tokens': 8192 }, timeout=120 ) resp.raise_for_status() result = resp.json() article_content = result['choices'][0]['message']['content'] except Exception as e: return jsonify({'error': f'生成失败: {str(e)}'}), 500 # 保存prompt历史 phid = str(uuid.uuid4())[:8] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn = get_db() conn.execute( 'INSERT INTO prompt_history (id, topic_id, prompt, model, created_at) VALUES (?,?,?,?,?)', (phid, topic_id, custom_prompt, llm_model, now) ) # 保存文章 aid = str(uuid.uuid4())[:8] conn.execute( 'INSERT INTO articles (id, topic_id, content, prompt_used, model, llm_url, created_at) VALUES (?,?,?,?,?,?,?)', (aid, topic_id, article_content, custom_prompt, llm_model, llm_url, now) ) conn.execute('UPDATE topics SET updated_at=? WHERE id=?', (now, topic_id)) conn.commit() article = conn.execute('SELECT * FROM articles WHERE id=?', (aid,)).fetchone() conn.close() return jsonify(dict(article)) @app.route('/api/articles/', methods=['DELETE']) def delete_article(article_id): conn = get_db() conn.execute('DELETE FROM articles WHERE id=?', (article_id,)) conn.commit() conn.close() return jsonify({'ok': True}) @app.route('/api/articles/', methods=['PUT']) def update_article(article_id): data = request.get_json(force=True) conn = get_db() conn.execute('UPDATE articles SET content=? WHERE id=?', (data.get('content', ''), article_id)) conn.commit() conn.close() return jsonify({'ok': True}) # ==================== 启动 ==================== if __name__ == '__main__': os.makedirs(UPLOAD_DIR, exist_ok=True) init_db() print('=' * 50) print(' 文章编写系统启动') print(' 访问地址: http://localhost:19200') print('=' * 50) app.run(host='0.0.0.0', port=19200, debug=False)