├── .env.example ├── .gitignore ├── .vscode ├── bookmarks.json └── launch.json ├── Dockerfile ├── assets ├── LNGPT-V2.0.png ├── Long-Novel-Agent.jpg ├── book-select.jpg ├── group.jpg └── write_text_preview.gif ├── backend ├── Dockerfile.backend ├── app.py ├── backend_utils.py ├── docker_run.sh ├── healthcheck.py ├── requirements.txt ├── setting.py ├── summary.py └── xapp.py ├── config.py ├── core ├── __init__.py ├── backend.py ├── diff_utils.py ├── draft_writer.py ├── frontend.py ├── frontend_copy.py ├── frontend_setting.py ├── frontend_utils.py ├── outline_writer.py ├── parser_utils.py ├── plot_writer.py ├── summary_novel.py ├── writer.py └── writer_utils.py ├── custom └── 根据提纲创作正文 │ ├── 天蚕土豆风格.txt │ └── 对草稿进行润色.txt ├── docker_build.sh ├── docker_run.sh ├── frontend ├── Dockerfile.frontend ├── data │ ├── example_novels │ │ ├── 凡人修仙传.yaml │ │ └── 斗破苍穹.yaml │ └── examples.yaml ├── docker_run.sh ├── index.html ├── js │ ├── chat_messages.js │ ├── content_section.js │ ├── copy_utils.js │ ├── novel_select.js │ ├── prompt_section.js │ ├── settings.js │ └── utils.js ├── nginx.conf └── styles │ ├── bottom_bar.css │ ├── chat_messages.css │ ├── examples.css │ ├── guide.css │ ├── novel_select.css │ ├── settings.css │ ├── styles.css │ ├── toast.css │ └── tooltip.css ├── install.md ├── llm_api ├── __init__.py ├── baidu_api.py ├── chat_messages.py ├── doubao_api.py ├── model_prices.json ├── mongodb_cache.py ├── mongodb_cost.py ├── mongodb_init.py ├── openai_api.py ├── sparkai_api.py └── zhipuai_api.py ├── prompts ├── baseprompt.py ├── chat_utils.py ├── common_parser.py ├── idea-examples.yaml ├── pf_parse_chat.py ├── prompt_utils.py ├── test_format_plot.yaml ├── test_prompt.py ├── text-plot-examples.yaml ├── tool_parser.py ├── tool_polish.py ├── 创作剧情 │ ├── context_prompt.txt │ ├── prompt.py │ ├── system_prompt.txt │ ├── 扩写剧情.txt │ ├── 新建剧情.txt │ ├── 格式化剧情.txt │ └── 润色剧情.txt ├── 创作正文 │ ├── context_prompt.txt │ ├── prompt.py │ ├── system_prompt.txt │ ├── 扩写正文.txt │ ├── 新建正文.txt │ ├── 格式化正文.txt │ └── 润色正文.txt ├── 创作章节 │ ├── context_prompt.txt │ ├── prompt.py │ ├── system_prompt.txt │ ├── 扩写章节.txt │ ├── 新建章节.txt │ ├── 格式化章节.txt │ └── 润色章节.txt ├── 审阅 │ ├── prompt.py │ ├── 审阅剧情.txt │ ├── 审阅大纲.txt │ └── 审阅正文.txt ├── 对齐剧情和正文 │ ├── prompt.jinja2 │ └── prompt.py ├── 提炼 │ ├── prompt.py │ ├── 提炼剧情.txt │ ├── 提炼大纲.txt │ └── 提炼章节.txt ├── 根据意见重写剧情 │ ├── prompt.jinja2 │ └── prompt.py ├── 根据意见重写正文 │ ├── prompt.jinja2 │ └── prompt.py ├── 根据提纲创作正文 │ ├── prompt.jinja2 │ └── prompt.py ├── 检索参考材料 │ ├── data.yaml │ ├── prompt.jinja2 │ └── prompt.py ├── 生成创作正文的上下文 │ ├── flow.dag.yaml │ ├── prompt.jinja2 │ └── prompt.py ├── 生成创作正文的意见 │ ├── flow.dag.yaml │ ├── parser.py │ ├── prompt.jinja2 │ └── prompt.py ├── 生成创作章节的上下文 │ ├── flow.dag.yaml │ ├── prompt.jinja2 │ └── prompt.py ├── 生成创作章节的意见 │ ├── flow.dag.yaml │ ├── parser.py │ ├── prompt.jinja2 │ └── prompt.py ├── 生成创作设定的意见 │ ├── data.yaml │ ├── prompt.jinja2 │ └── prompt.py ├── 生成重写正文的意见 │ ├── prompt.jinja2 │ └── prompt.py └── 生成重写章节的意见 │ ├── flow.dag.yaml │ ├── parser.py │ ├── prompt.jinja2 │ └── prompt.py ├── readme.md ├── start.sh ├── tests ├── test_summary.py └── test_writer.py └── tmp.yaml /.env.example: -------------------------------------------------------------------------------- 1 | # Thread Configuration - 线程配置 2 | # 生成时采用的最大线程数,5-10即可。会带来成倍的API调用费用,不要设置过高! 3 | MAX_THREAD_NUM=5 4 | 5 | 6 | # Server Configuration - Docker服务配置 7 | # 前端服务端口 8 | FRONTEND_PORT=80 9 | # 后端服务端口 10 | BACKEND_PORT=7869 11 | # 后端服务监听地址 12 | BACKEND_HOST=0.0.0.0 13 | # Gunicorn工作进程数 14 | WORKERS=4 15 | # 每个工作进程的线程数 16 | THREADS=2 17 | # 请求超时时间(秒) 18 | TIMEOUT=120 19 | 20 | # 是否启用在线演示 21 | # 不用设置,默认不启用 22 | ENABLE_ONLINE_DEMO=False 23 | 24 | # Backend Configuration - 后端配置 25 | # 导入小说时,最大的处理长度,超出该长度的文本不会进行处理,可以考虑增加 26 | MAX_NOVEL_SUMMARY_LENGTH=20000 27 | 28 | 29 | # MongoDB Configuration - MongoDB数据库配置 30 | # 安装了MongoDB才需要配置,否则不用改动 31 | # 是否启用MongoDB,启用后下面配置才有效 32 | ENABLE_MONGODB=false 33 | # MongoDB连接地址,使用host.docker.internal访问宿主机MongoDB 34 | MONGODB_URI=mongodb://host.docker.internal:27017/ 35 | # MongoDB数据库名称 36 | MONGODB_DB_NAME=llm_api 37 | # 是否启用API缓存 38 | ENABLE_MONGODB_CACHE=true 39 | # 缓存命中后重放速度倍率 40 | CACHE_REPLAY_SPEED=2 41 | # 缓存命中后最大延迟时间(秒) 42 | CACHE_REPLAY_MAX_DELAY=5 43 | 44 | 45 | # API Cost Limits - API费用限制设置,需要依赖于MongoDB 46 | # 每小时费用上限(人民币) 47 | API_HOURLY_LIMIT_RMB=100 48 | # 每天费用上限(人民币) 49 | API_DAILY_LIMIT_RMB=500 50 | # 美元兑人民币汇率 51 | API_USD_TO_RMB_RATE=7 52 | 53 | 54 | # Wenxin API Settings - 文心API配置 55 | # 文心API的AK,获取地址:https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application 56 | WENXIN_AK= 57 | WENXIN_SK= 58 | WENXIN_AVAILABLE_MODELS=ERNIE-Novel-8K,ERNIE-4.0-8K,ERNIE-3.5-8K 59 | 60 | # Doubao API Settings - 豆包API配置 61 | # DOUBAO_ENDPOINT_IDS和DOUBAO_AVAILABLE_MODELS一一对应,有几个模型就对应几个endpoint_id,这是豆包强制要求的 62 | # 你可以自行设置DOUBAO_AVAILABLE_MODELS,不一定非要采用下面的 63 | DOUBAO_API_KEY= 64 | DOUBAO_ENDPOINT_IDS= 65 | DOUBAO_AVAILABLE_MODELS=doubao-pro-32k,doubao-lite-32k 66 | 67 | # GPT API Settings - GPT API配置 68 | GPT_BASE_URL= 69 | GPT_API_KEY= 70 | GPT_AVAILABLE_MODELS=gpt-4o,gpt-4o-mini 71 | 72 | # Local Model Settings - 本地模型配置 73 | # 本地模型配置需要把下面的localhost替换为host.docker.internal,把8000替换为你的本地大模型服务端口 74 | # 把local-key替换为你的本地大模型服务API_KEY,把local-model-1替换为你的本地大模型服务模型名 75 | # 并且docker启动方式有变化,详细参考readme 76 | LOCAL_BASE_URL=http://localhost:8000/v1 77 | LOCAL_API_KEY=local-key 78 | LOCAL_AVAILABLE_MODELS=local-model-1 79 | 80 | # Zhipuai API Settings - 智谱AI配置 81 | ZHIPUAI_API_KEY= 82 | ZHIPUAI_AVAILABLE_MODELS=glm-4-air,glm-4-flashx 83 | 84 | # Default Model Settings - 默认模型设置 85 | # 例如:wenxin/ERNIE-Novel-8K, doubao/doubao-pro-32k, gpt/gpt-4o-mini, local/local-model-1 86 | DEFAULT_MAIN_MODEL=zhipuai/glm-4-air 87 | DEFAULT_SUB_MODEL=zhipuai/glm-4-flashx 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output 2 | config.json 3 | __pycache__ 4 | test.py 5 | .promptflow 6 | demo/main_chat_messages.json 7 | prompts/chat_basic 8 | .vscode/settings.json 9 | demo/config.py 10 | **/default.flow_test.yaml 11 | **/data.jsonl 12 | *.pickle 13 | .vscode/sftp.json 14 | data/* 15 | todo.md 16 | states.json 17 | "states copy.json" 18 | 19 | 20 | # Environment variables 21 | .env 22 | .env.local 23 | .env.*.local 24 | 25 | # Python 26 | __pycache__/ 27 | *.py[cod] 28 | *$py.class 29 | venv/ 30 | .venv/ 31 | 32 | # Node 33 | node_modules/ 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | 38 | # IDE 39 | *.swp 40 | *.swo 41 | 42 | # OS 43 | .DS_Store 44 | Thumbs.db -------------------------------------------------------------------------------- /.vscode/bookmarks.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | { 4 | "path": "frontend/js/settings.js", 5 | "bookmarks": [ 6 | { 7 | "line": 189, 8 | "column": 9, 9 | "label": "" 10 | } 11 | ] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // python -m core.frontend 8 | { 9 | "name": "Backend", 10 | "type": "debugpy", 11 | "request": "launch", 12 | "module": "backend.app", 13 | "console": "integratedTerminal", 14 | "justMyCode": true, 15 | "env": { 16 | "BACKEND_PORT": "7869", 17 | "FRONTEND_PORT": "9999" 18 | } 19 | },{ 20 | "name": "Test", 21 | "type": "debugpy", 22 | "request": "launch", 23 | "module": "tests.test_main", 24 | "console": "integratedTerminal", 25 | "justMyCode": false 26 | }, 27 | ] 28 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install nginx and iproute2 6 | RUN apt-get update && apt-get install -y nginx iproute2 && rm -rf /var/lib/apt/lists/* \ 7 | && rm -f /etc/nginx/sites-enabled/default \ 8 | && rm -f /etc/nginx/sites-available/default 9 | 10 | # Copy requirements first for better caching 11 | COPY backend/requirements.txt . 12 | RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple 13 | RUN pip install -r requirements.txt 14 | 15 | # Copy backend files 16 | COPY config.py . 17 | COPY backend/app.py . 18 | COPY backend/setting.py . 19 | COPY backend/summary.py . 20 | COPY backend/backend_utils.py . 21 | COPY backend/healthcheck.py . 22 | COPY core/ ./core/ 23 | COPY llm_api/ ./llm_api/ 24 | COPY prompts/ ./prompts/ 25 | COPY custom/ ./custom/ 26 | 27 | # Copy frontend files 28 | COPY frontend/index.html /usr/share/nginx/html/ 29 | COPY frontend/js/ /usr/share/nginx/html/js/ 30 | COPY frontend/styles/ /usr/share/nginx/html/styles/ 31 | COPY frontend/data/ /usr/share/nginx/html/data/ 32 | 33 | # Copy nginx configuration 34 | COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf 35 | 36 | # Copy start script 37 | COPY start.sh . 38 | RUN chmod +x start.sh 39 | 40 | # EXPOSE $FRONTEND_PORT 41 | 42 | CMD ["./start.sh"] -------------------------------------------------------------------------------- /assets/LNGPT-V2.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaoXiaoYuZ/Long-Novel-GPT/189e5c81ef175a7f494241b5c95c02975ed5e073/assets/LNGPT-V2.0.png -------------------------------------------------------------------------------- /assets/Long-Novel-Agent.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaoXiaoYuZ/Long-Novel-GPT/189e5c81ef175a7f494241b5c95c02975ed5e073/assets/Long-Novel-Agent.jpg -------------------------------------------------------------------------------- /assets/book-select.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaoXiaoYuZ/Long-Novel-GPT/189e5c81ef175a7f494241b5c95c02975ed5e073/assets/book-select.jpg -------------------------------------------------------------------------------- /assets/group.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaoXiaoYuZ/Long-Novel-GPT/189e5c81ef175a7f494241b5c95c02975ed5e073/assets/group.jpg -------------------------------------------------------------------------------- /assets/write_text_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaoXiaoYuZ/Long-Novel-GPT/189e5c81ef175a7f494241b5c95c02975ed5e073/assets/write_text_preview.gif -------------------------------------------------------------------------------- /backend/Dockerfile.backend: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | # Copy requirements first for better caching 6 | COPY backend/requirements.txt . 7 | RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple 8 | RUN pip install -r requirements.txt 9 | 10 | # Copy all necessary project files 11 | COPY config.py . 12 | COPY .env . 13 | COPY backend/app.py . 14 | COPY backend/healthcheck.py . 15 | COPY core/ ./core/ 16 | COPY llm_api/ ./llm_api/ 17 | COPY prompts/ ./prompts/ 18 | COPY custom/ ./custom/ 19 | 20 | EXPOSE 7869 21 | 22 | # 优化 gunicorn 配置 23 | CMD ["gunicorn", "--bind", "0.0.0.0:7869", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "app:app"] -------------------------------------------------------------------------------- /backend/backend_utils.py: -------------------------------------------------------------------------------- 1 | from llm_api import ModelConfig 2 | 3 | def get_model_config_from_provider_model(provider_model): 4 | from config import API_SETTINGS 5 | provider, model = provider_model.split('/', 1) 6 | provider_config = API_SETTINGS[provider] 7 | 8 | if provider == 'doubao': 9 | # Get the index of the model in available_models to find corresponding endpoint_id 10 | model_index = provider_config['available_models'].index(model) 11 | endpoint_id = provider_config['endpoint_ids'][model_index] if model_index < len(provider_config['endpoint_ids']) else '' 12 | model_config = {**provider_config, 'model': model, 'endpoint_id': endpoint_id} 13 | else: 14 | model_config = {**provider_config, 'model': model} 15 | 16 | # Remove lists from config before creating ModelConfig 17 | if 'available_models' in model_config: 18 | del model_config['available_models'] 19 | if 'endpoint_ids' in model_config: 20 | del model_config['endpoint_ids'] 21 | 22 | return ModelConfig(**model_config) -------------------------------------------------------------------------------- /backend/docker_run.sh: -------------------------------------------------------------------------------- 1 | # 单独构建并运行后端 2 | docker rm -f $(docker ps -a | grep lngpt-backend | awk '{print $1}') 2>/dev/null || true 3 | cd .. 4 | docker build -t lngpt-backend -f backend/Dockerfile.backend . 5 | docker run -p 7869:7869 --add-host=host.docker.internal:host-gateway -d lngpt-backend 6 | 7 | # 添加host.docker.internal是为了访问mongodb服务 8 | -------------------------------------------------------------------------------- /backend/healthcheck.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import sys 3 | import os 4 | 5 | BACKEND_PORT = int(os.environ.get('BACKEND_PORT', 7869)) 6 | 7 | 8 | def check_health(): 9 | try: 10 | conn = http.client.HTTPConnection("localhost", BACKEND_PORT) 11 | conn.request("GET", "/health") 12 | response = conn.getresponse() 13 | if response.status == 200: 14 | print("Health check passed") 15 | return True 16 | else: 17 | print(f"Health check failed: {response.status}") 18 | return False 19 | except Exception as e: 20 | print(f"Health check failed: {e}", file=sys.stderr) 21 | return False 22 | 23 | if __name__ == "__main__": 24 | sys.exit(0 if check_health() else 1) 25 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-cors 3 | gunicorn 4 | openai 5 | qianfan 6 | pymongo 7 | rich 8 | spark_ai_python 9 | zhipuai 10 | flask 11 | flask-cors 12 | numpy 13 | chardet -------------------------------------------------------------------------------- /backend/setting.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | 3 | setting_bp = Blueprint('setting', __name__) 4 | 5 | @setting_bp.route('/setting', methods=['GET']) 6 | def get_settings(): 7 | """Get current settings and models""" 8 | from config import API_SETTINGS, DEFAULT_MAIN_MODEL, DEFAULT_SUB_MODEL, MAX_THREAD_NUM, MAX_NOVEL_SUMMARY_LENGTH 9 | 10 | # Get models grouped by provider 11 | models = {provider: config['available_models'] for provider, config in API_SETTINGS.items() if 'available_models' in config} 12 | 13 | # Combine all settings 14 | settings = { 15 | 'models': models, 16 | 'MAIN_MODEL': DEFAULT_MAIN_MODEL, 17 | 'SUB_MODEL': DEFAULT_SUB_MODEL, 18 | 'MAX_THREAD_NUM': MAX_THREAD_NUM, 19 | 'MAX_NOVEL_SUMMARY_LENGTH': MAX_NOVEL_SUMMARY_LENGTH, 20 | } 21 | return jsonify(settings) 22 | 23 | @setting_bp.route('/test_model', methods=['POST']) 24 | def test_model(): 25 | """Test if a model configuration works""" 26 | try: 27 | data = request.get_json() 28 | provider_model = data.get('provider_model') 29 | 30 | from backend_utils import get_model_config_from_provider_model 31 | model_config = get_model_config_from_provider_model(provider_model) 32 | 33 | from llm_api import test_stream_chat 34 | response = None 35 | for msg in test_stream_chat(model_config): 36 | response = msg 37 | 38 | return jsonify({ 39 | 'success': True, 40 | 'response': response 41 | }) 42 | except Exception as e: 43 | return jsonify({ 44 | 'success': False, 45 | 'error': str(e) 46 | }), 500 47 | -------------------------------------------------------------------------------- /backend/summary.py: -------------------------------------------------------------------------------- 1 | import time 2 | from core.parser_utils import parse_chapters 3 | from core.summary_novel import summary_draft, summary_plot, summary_chapters 4 | from config import MAX_NOVEL_SUMMARY_LENGTH, MAX_THREAD_NUM, ENABLE_ONLINE_DEMO 5 | 6 | def batch_yield(generators, max_co_num=5, ret=[]): 7 | results = [None] * len(generators) 8 | yields = [None] * len(generators) 9 | finished = [False] * len(generators) 10 | 11 | while True: 12 | co_num = 0 13 | for i, gen in enumerate(generators): 14 | if finished[i]: 15 | continue 16 | 17 | try: 18 | co_num += 1 19 | yield_value = next(gen) 20 | yields[i] = yield_value 21 | except StopIteration as e: 22 | results[i] = e.value 23 | finished[i] = True 24 | 25 | if co_num >= max_co_num: 26 | break 27 | 28 | if all(finished): 29 | break 30 | 31 | yield yields 32 | 33 | ret.clear() 34 | ret.extend(results) 35 | return ret 36 | 37 | def process_novel(content, novel_name, model, sub_model, max_novel_summary_length, max_thread_num): 38 | if ENABLE_ONLINE_DEMO: 39 | if max_novel_summary_length > MAX_NOVEL_SUMMARY_LENGTH: 40 | raise Exception("在线Demo模型下,最大小说长度不能超过" + str(MAX_NOVEL_SUMMARY_LENGTH) + "个字符!") 41 | if max_thread_num > MAX_THREAD_NUM: 42 | raise Exception("在线Demo模型下,最大线程数不能超过" + str(MAX_THREAD_NUM) + "!") 43 | 44 | if len(content) > max_novel_summary_length: 45 | content = content[:max_novel_summary_length] 46 | yield {"progress_msg": f"小说长度超出最大处理长度,已截断,只处理前{max_novel_summary_length}个字符。"} 47 | time.sleep(1) 48 | 49 | # Parse chapters 50 | yield {"progress_msg": "正在解析章节..."} 51 | 52 | chapter_titles, chapter_contents = parse_chapters(content) 53 | 54 | yield {"progress_msg": "解析出章节数:" + str(len(chapter_titles))} 55 | 56 | if len(chapter_titles) == 0: 57 | raise Exception("解析出章节数为0!!!") 58 | 59 | # Process draft summaries 60 | yield {"progress_msg": "正在生成剧情摘要..."} 61 | dw_list = [] 62 | gens = [summary_draft(model, sub_model, ' '.join(title), content) for title, content in zip(chapter_titles, chapter_contents)] 63 | for yields in batch_yield(gens, ret=dw_list, max_co_num=max_thread_num): 64 | chars_num = sum([e['chars_num'] for e in yields if e is not None]) 65 | current_cost = sum([e['current_cost'] for e in yields if e is not None]) 66 | currency_symbol = next(e['currency_symbol'] for e in yields if e is not None) 67 | model_text = next(e['model'] for e in yields if e is not None) 68 | yield {"progress_msg": f"正在生成剧情摘要 进度:{sum([1 for e in yields if e is not None])} / {len(yields)} 模型:{model_text} 已生成字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}"} 69 | 70 | # Process plot summaries 71 | yield {"progress_msg": "正在生成章节大纲..."} 72 | cw_list = [] 73 | gens = [summary_plot(model, sub_model, ' '.join(title), dw.x) for title, dw in zip(chapter_titles, dw_list)] 74 | for yields in batch_yield(gens, ret=cw_list, max_co_num=max_thread_num): 75 | chars_num = sum([e['chars_num'] for e in yields if e is not None]) 76 | current_cost = sum([e['current_cost'] for e in yields if e is not None]) 77 | currency_symbol = next(e['currency_symbol'] for e in yields if e is not None) 78 | model_text = next(e['model'] for e in yields if e is not None) 79 | yield {"progress_msg": f"正在生成章节大纲 进度:{sum([1 for e in yields if e is not None])} / {len(yields)} 模型:{model_text} 已生成字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}"} 80 | 81 | # Process chapter summaries 82 | yield {"progress_msg": "正在生成全书大纲..."} 83 | ow_list = [] 84 | gens = [summary_chapters(model, sub_model, novel_name, chapter_titles, [cw.global_context['chapter'] for cw in cw_list])] 85 | for yields in batch_yield(gens, ret=ow_list, max_co_num=max_thread_num): 86 | chars_num = sum([e['chars_num'] for e in yields if e is not None]) 87 | current_cost = sum([e['current_cost'] for e in yields if e is not None]) 88 | currency_symbol = next(e['currency_symbol'] for e in yields if e is not None) 89 | model_text = next(e['model'] for e in yields if e is not None) 90 | yield {"progress_msg": f"正在生成全书大纲 模型:{model_text} 已生成字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}"} 91 | 92 | # Prepare final response 93 | outline = ow_list[0] 94 | plot_data = {} 95 | draft_data = {} 96 | 97 | for title, chapter_outline, cw, dw in zip(chapter_titles, [e[1] for e in outline.xy_pairs], cw_list, dw_list): 98 | chapter_name = ' '.join(title) 99 | plot_data[chapter_name] = { 100 | 'chunks': [('', e) for e, _ in dw.xy_pairs], 101 | 'context': chapter_outline # 不采用cw.global_context['chapter'],因为不含章节名 102 | } 103 | draft_data[chapter_name] = { 104 | 'chunks': dw.xy_pairs, 105 | 'context': '' # Draft doesn't have global context 106 | } 107 | 108 | final_response = { 109 | "progress_msg": "处理完成!", 110 | "outline": { 111 | "chunks": outline.xy_pairs, 112 | "context": outline.global_context['outline'] 113 | }, 114 | "plot": plot_data, 115 | "draft": draft_data 116 | } 117 | 118 | yield final_response 119 | -------------------------------------------------------------------------------- /backend/xapp.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, Response, jsonify 2 | from flask_cors import CORS 3 | import json 4 | import time 5 | import random 6 | import os 7 | 8 | app = Flask(__name__) 9 | CORS(app) 10 | 11 | # 添加配置 12 | BACKEND_HOST = os.environ.get('BACKEND_HOST', '0.0.0.0') 13 | BACKEND_PORT = int(os.environ.get('BACKEND_PORT', 7869)) 14 | 15 | @app.route('/health', methods=['GET']) 16 | def health_check(): 17 | return jsonify({ 18 | 'status': 'healthy', 19 | 'timestamp': int(time.time()) 20 | }), 200 21 | 22 | # Add prompts data 23 | PROMPTS = { 24 | "outline": { 25 | "新建章节": { 26 | "content": "你需要参考'小说简介',创作一个完整的全书章节。\n按下面步骤输出:\n1.思考整个小说的故事结构\n2.创作完整的全书章节" 27 | }, 28 | "扩写章节": { 29 | "content": "基于已有章节进行扩写和完善,使其更加详细和具体。\n请注意:\n1.保持原有故事框架\n2.添加更多细节和支线" 30 | }, 31 | "润色章节": { 32 | "content": "对现有章节进行优化和润色。\n重点关注:\n1.故事结构的完整性\n2.情节的合理性\n3.叙事的流畅度" 33 | } 34 | }, 35 | "plot": { 36 | "新建剧情": { 37 | "content": "根据章节创作具体剧情。\n请按以下步骤:\n1.细化场景描写\n2.丰富人物对话\n3.展现情节发展" 38 | }, 39 | "扩写剧情": { 40 | "content": "在现有剧情基础上进行扩写。\n重点:\n1.增加细节描写\n2.深化人物刻画\n3.完善情节转折" 41 | } 42 | }, 43 | "draft": { 44 | "创作正文": { 45 | "content": "将剧情转化为完整的小说正文。\n要求:\n1.优美的文字描写\n2.生动的场景刻画\n3.丰富的情感表达" 46 | }, 47 | "修改正文": { 48 | "content": "对现有正文进行修改和完善。\n关注:\n1.文字表达\n2.情节连贯性\n3.人物塑造" 49 | } 50 | } 51 | } 52 | 53 | @app.route('/prompts', methods=['GET']) 54 | def get_prompts(): 55 | return jsonify(PROMPTS) 56 | 57 | def get_delta_chunks(prev_chunks, curr_chunks): 58 | """Calculate delta between previous and current chunks""" 59 | if not prev_chunks or len(prev_chunks) != len(curr_chunks): 60 | return "init", curr_chunks 61 | 62 | # Check if all strings in current chunks start with their corresponding previous strings 63 | is_delta = True 64 | for prev_chunk, curr_chunk in zip(prev_chunks, curr_chunks): 65 | if len(prev_chunk) != len(curr_chunk): 66 | is_delta = False 67 | break 68 | for prev_str, curr_str in zip(prev_chunk, curr_chunk): 69 | if not curr_str.startswith(prev_str): 70 | is_delta = False 71 | break 72 | if not is_delta: 73 | break 74 | 75 | if not is_delta: 76 | return "init", curr_chunks 77 | 78 | # Calculate deltas 79 | delta_chunks = [] 80 | for prev_chunk, curr_chunk in zip(prev_chunks, curr_chunks): 81 | delta_chunk = [] 82 | for prev_str, curr_str in zip(prev_chunk, curr_chunk): 83 | delta_str = curr_str[len(prev_str):] 84 | delta_chunk.append(delta_str) 85 | delta_chunks.append(delta_chunk) 86 | 87 | return "delta", delta_chunks 88 | 89 | 90 | def write_chunks(chunk_list, chunk_span, writer_mode, prompt_content, x_chunk_length, y_chunk_length): 91 | """修改测试用的流式生成函数,添加窗口大小参数""" 92 | # 生成新的chunks(这里简单演示生成3个chunk) 93 | new_chunks = [ 94 | ["章节1", "内容1"], 95 | ["章节2", "内容2"], 96 | ["章节3", "内容3"], 97 | ] 98 | 99 | # 模拟流式生成过程 100 | partial_texts = [""] * len(new_chunks) 101 | sentences = [f"这是第{i}句修改。" for i in range(1, random.randint(13, 20))] 102 | 103 | prev_chunks = None 104 | def delta_wrapper(chunk_list, done=False): 105 | nonlocal prev_chunks 106 | if prev_chunks is None: 107 | prev_chunks = chunk_list 108 | return { 109 | "done": done, 110 | "chunk_type": "init", 111 | "chunk_list": chunk_list 112 | } 113 | else: 114 | chunk_type, new_chunks = get_delta_chunks(prev_chunks, chunk_list) 115 | prev_chunks = chunk_list 116 | return { 117 | "done": done, 118 | "chunk_type": chunk_type, 119 | "chunk_list": new_chunks 120 | } 121 | 122 | 123 | for sentence in sentences: 124 | # 并行更新所有chunk的内容 125 | for i in range(len(new_chunks)): 126 | partial_texts[i] += sentence 127 | 128 | current_chunks = [ 129 | [x, y, text] for (x, y), text in zip(new_chunks, partial_texts) 130 | ] 131 | 132 | yield delta_wrapper(current_chunks) 133 | 134 | time.sleep(0.1) # 模拟生成延迟 135 | 136 | # 最终完成状态 137 | final_chunks = [ 138 | [x, y, text] for (x, y), text in zip(new_chunks, partial_texts) 139 | ] 140 | yield delta_wrapper(final_chunks, done=True) 141 | 142 | @app.route('/write', methods=['POST']) 143 | def write(): 144 | data = request.json 145 | writer_mode = data['writer_mode'] 146 | chunk_list = data['chunk_list'] 147 | chunk_span = data['chunk_span'] 148 | prompt_content = data['prompt_content'] 149 | x_chunk_length = data['x_chunk_length'] 150 | y_chunk_length = data['y_chunk_length'] 151 | 152 | def generate(): 153 | for result in write_chunks(chunk_list, chunk_span, writer_mode, prompt_content, x_chunk_length, y_chunk_length): 154 | yield f"data: {json.dumps(result)}\n\n" 155 | 156 | return Response(generate(), mimetype='text/event-stream') 157 | 158 | if __name__ == '__main__': 159 | app.run(host=BACKEND_HOST, port=BACKEND_PORT, debug=False) -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import dotenv_values, load_dotenv 3 | 4 | print("Loading .env file...") 5 | env_path = os.path.join(os.path.dirname(__file__), '.env') 6 | if os.path.exists(env_path): 7 | env_dict = dotenv_values(env_path) 8 | 9 | print("Environment variables to be loaded:") 10 | for key, value in env_dict.items(): 11 | print(f"{key}={value}") 12 | print("-" * 50) 13 | 14 | os.environ.update(env_dict) 15 | print(f"Loaded environment variables from: {env_path}") 16 | else: 17 | print("Warning: .env file not found") 18 | 19 | 20 | # Thread Configuration 21 | MAX_THREAD_NUM = int(os.getenv('MAX_THREAD_NUM', 5)) 22 | 23 | 24 | MAX_NOVEL_SUMMARY_LENGTH = int(os.getenv('MAX_NOVEL_SUMMARY_LENGTH', 20000)) 25 | 26 | # MongoDB Configuration 27 | ENABLE_MONOGODB = os.getenv('ENABLE_MONGODB', 'false').lower() == 'true' 28 | MONGODB_URI = os.getenv('MONGODB_URI', 'mongodb://127.0.0.1:27017/') 29 | MONOGODB_DB_NAME = os.getenv('MONGODB_DB_NAME', 'llm_api') 30 | ENABLE_MONOGODB_CACHE = os.getenv('ENABLE_MONGODB_CACHE', 'true').lower() == 'true' 31 | CACHE_REPLAY_SPEED = float(os.getenv('CACHE_REPLAY_SPEED', 2)) 32 | CACHE_REPLAY_MAX_DELAY = float(os.getenv('CACHE_REPLAY_MAX_DELAY', 5)) 33 | 34 | # API Cost Limits 35 | API_COST_LIMITS = { 36 | 'HOURLY_LIMIT_RMB': float(os.getenv('API_HOURLY_LIMIT_RMB', 100)), 37 | 'DAILY_LIMIT_RMB': float(os.getenv('API_DAILY_LIMIT_RMB', 500)), 38 | 'USD_TO_RMB_RATE': float(os.getenv('API_USD_TO_RMB_RATE', 7)) 39 | } 40 | 41 | # API Settings 42 | API_SETTINGS = { 43 | 'wenxin': { 44 | 'ak': os.getenv('WENXIN_AK', ''), 45 | 'sk': os.getenv('WENXIN_SK', ''), 46 | 'available_models': os.getenv('WENXIN_AVAILABLE_MODELS', '').split(','), 47 | 'max_tokens': 4096, 48 | }, 49 | 'doubao': { 50 | 'api_key': os.getenv('DOUBAO_API_KEY', ''), 51 | 'endpoint_ids': os.getenv('DOUBAO_ENDPOINT_IDS', '').split(','), 52 | 'available_models': os.getenv('DOUBAO_AVAILABLE_MODELS', '').split(','), 53 | 'max_tokens': 4096, 54 | }, 55 | 'gpt': { 56 | 'base_url': os.getenv('GPT_BASE_URL', ''), 57 | 'api_key': os.getenv('GPT_API_KEY', ''), 58 | 'proxies': os.getenv('GPT_PROXIES', ''), 59 | 'available_models': os.getenv('GPT_AVAILABLE_MODELS', '').split(','), 60 | 'max_tokens': 4096, 61 | }, 62 | 'zhipuai': { 63 | 'api_key': os.getenv('ZHIPUAI_API_KEY', ''), 64 | 'available_models': os.getenv('ZHIPUAI_AVAILABLE_MODELS', '').split(','), 65 | 'max_tokens': 4096, 66 | }, 67 | 'local': { 68 | 'base_url': os.getenv('LOCAL_BASE_URL', ''), 69 | 'api_key': os.getenv('LOCAL_API_KEY', ''), 70 | 'available_models': os.getenv('LOCAL_AVAILABLE_MODELS', '').split(','), 71 | 'max_tokens': 4096, 72 | } 73 | } 74 | 75 | for model in API_SETTINGS.values(): 76 | model['available_models'] = [e.strip() for e in model['available_models']] 77 | 78 | DEFAULT_MAIN_MODEL = os.getenv('DEFAULT_MAIN_MODEL', 'wenxin/ERNIE-Novel-8K') 79 | DEFAULT_SUB_MODEL = os.getenv('DEFAULT_SUB_MODEL', 'wenxin/ERNIE-3.5-8K') 80 | 81 | ENABLE_ONLINE_DEMO = os.getenv('ENABLE_ONLINE_DEMO', 'false').lower() == 'true' -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | # core 模块为LongNovelGPT到2.0版本之间的过渡,在core模块中进行一些新功能和设计的尝试 2 | -------------------------------------------------------------------------------- /core/diff_utils.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from difflib import SequenceMatcher 3 | 4 | 5 | def match_span_by_char(text, chunk): 6 | # 用来存储从text中找到的符合匹配的行的span 7 | spans = [] 8 | 9 | # 使用difflib来寻找最佳匹配行 10 | matcher = difflib.SequenceMatcher(None, text, chunk) 11 | 12 | # 获取匹配块信息 13 | for tag, i1, i2, j1, j2 in matcher.get_opcodes(): 14 | if tag == 'equal': 15 | # 记录匹配行的起始和结束索引 16 | spans.append((i1, i2)) 17 | 18 | if spans: 19 | match_span = (spans[0][0], spans[-1][1]) 20 | match_ratio = sum(i2 - i1 for i1, i2 in spans) / len(chunk) 21 | return match_span, match_ratio 22 | else: 23 | return None, 0 24 | 25 | def match_sequences(a_list, b_list): 26 | """ 27 | 匹配两个字符串列表,返回匹配的索引对 28 | 29 | Args: 30 | a_list: 第一个字符串列表 31 | b_list: 第二个字符串列表 32 | 33 | Returns: 34 | list[((l,r), (j,k))]: 匹配的索引对列表, 35 | 其中(l,r)表示a_list的起始和结束索引,(j,k)表示b_list的起始和结束索引 36 | """ 37 | m, n = len(a_list) - 1, len(b_list) - 1 38 | matches = [] 39 | i = j = 0 40 | 41 | while i < m and j < n: 42 | # 初始化当前最佳匹配 43 | best_match = None 44 | best_ratio = -1 # 设置匹配阈值 45 | 46 | # 尝试从当前位置开始的不同组合 47 | for l in range(i, min(i + 3, m)): # 限制向前查找的范围 48 | current_a = ''.join(a_list[i:l + 1]) 49 | 50 | for r in range(j, min(j + 3, n)): # 限制向前查找的范围 51 | current_b = ''.join(b_list[j:r + 1]) 52 | 53 | # 使用已有的match_span_by_char函数计算匹配度 54 | span1, ratio1 = match_span_by_char(current_b, current_a) 55 | span2, ratio2 = match_span_by_char(current_a, current_b) 56 | ratio = ratio1 * ratio2 57 | 58 | if ratio > best_ratio: 59 | best_ratio = ratio 60 | best_match = ((i, l + 1), (j, r + 1)) 61 | 62 | if best_match: 63 | matches.append(best_match) 64 | i = best_match[0][1] 65 | j = best_match[1][1] 66 | else: 67 | # 如果没找到好的匹配,向前移动一步 68 | i += 1 69 | j += 1 70 | 71 | matches.append(((i, m+1), (j, n+1))) 72 | 73 | return matches 74 | 75 | def get_chunk_changes(source_chunk_list, target_chunk_list): 76 | SEPARATOR = "%|%" 77 | source_text = SEPARATOR.join(source_chunk_list) 78 | target_text = SEPARATOR.join(target_chunk_list) 79 | 80 | # 初始化每个chunk的tag统计 81 | source_chunk_stats = [{'delete_or_insert': 0, 'replace_or_equal': 0} for _ in source_chunk_list] 82 | target_chunk_stats = [{'delete_or_insert': 0, 'replace_or_equal': 0} for _ in target_chunk_list] 83 | 84 | # 获取chunk的起始位置列表 85 | source_positions = [0] 86 | target_positions = [0] 87 | pos = 0 88 | for chunk in source_chunk_list[:-1]: 89 | pos += len(chunk) + len(SEPARATOR) 90 | source_positions.append(pos) 91 | source_positions.append(len(source_text)) 92 | 93 | pos = 0 94 | for chunk in target_chunk_list[:-1]: 95 | pos += len(chunk) + len(SEPARATOR) 96 | target_positions.append(pos) 97 | target_positions.append(len(target_text)) 98 | 99 | def update_chunk_stats(positions, stats, start, end, tag): 100 | for i in range(len(positions) - 1): 101 | chunk_start = positions[i] 102 | chunk_end = positions[i + 1] 103 | 104 | overlap_start = max(chunk_start, start) 105 | overlap_end = min(chunk_end, end) 106 | 107 | if overlap_end > overlap_start: 108 | stats[i][tag] += overlap_end - overlap_start 109 | 110 | matcher = SequenceMatcher(None, source_text, target_text) 111 | 112 | # 处理每个操作块并更新统计信息 113 | for tag, i1, i2, j1, j2 in matcher.get_opcodes(): 114 | if tag == 'replace' or tag == 'equal': 115 | update_chunk_stats(source_positions, source_chunk_stats, i1, i2, 'replace_or_equal') 116 | update_chunk_stats(target_positions, target_chunk_stats, j1, j2, 'replace_or_equal') 117 | elif tag == 'delete': 118 | update_chunk_stats(source_positions, source_chunk_stats, i1, i2, 'delete_or_insert') 119 | elif tag == 'insert': 120 | update_chunk_stats(target_positions, target_chunk_stats, j1, j2, 'delete_or_insert') 121 | 122 | # 确定每个chunk的最终tag 123 | def get_final_tag(stats): 124 | return 'delete_or_insert' if stats['delete_or_insert'] > stats['replace_or_equal'] else 'replace_or_equal' 125 | 126 | source_chunk_tags = [get_final_tag(stats) for stats in source_chunk_stats] 127 | target_chunk_tags = [get_final_tag(stats) for stats in target_chunk_stats] 128 | 129 | # 使用双指针计算changes 130 | changes = [] 131 | i = j = 0 # i指向source_chunk_list,j指向target_chunk_list 132 | start_i = start_j = 0 133 | m, n = len(source_chunk_list), len(target_chunk_list) 134 | while i < m or j < n: 135 | if i < m and source_chunk_tags[i] == 'delete_or_insert': 136 | while i < m and source_chunk_tags[i] == 'delete_or_insert': i += 1 137 | elif j < n and target_chunk_tags[j] == 'delete_or_insert': 138 | while j < n and target_chunk_tags[j] == 'delete_or_insert': j += 1 139 | elif i < m and j < n and source_chunk_tags[i] == 'replace_or_equal' and target_chunk_tags[j] == 'replace_or_equal': 140 | while i < m and j < n and source_chunk_tags[i] == 'replace_or_equal' and target_chunk_tags[j] == 'replace_or_equal': 141 | i += 1 142 | j += 1 143 | else: 144 | # TODO: 这个算法目前还有一些问题,即equal的对应 145 | break 146 | 147 | # 当有任意一个指针移动时,检查是否需要添加change 148 | if (i > start_i or j > start_j): 149 | changes.append((start_i, i, start_j, j)) 150 | start_i, start_j = i, j 151 | 152 | if (i < m or j < n): 153 | changes.append((start_i, m, start_j, n)) 154 | 155 | return changes 156 | 157 | 158 | # 使用示例 159 | def test_get_chunk_changes(): 160 | source_chunks = ['', '', '', '第3章 初露锋芒\n在高人指导下,萧炎的斗气水平迅速提升,开始在家族中引起注意。\n', '', '第4章 异火初现\n萧炎得知“异火”的存在,决定踏上寻找异火的旅程。\n'] 161 | target_chunks = ['', '第3章 初露锋芒\n在高人指导下,萧炎的斗气水平迅速提升,开始在家族中引起注意。', '第3.5章 家族试炼\n萧炎参加家族举办的试炼,凭借新学的斗技和炼丹术,展现出超凡实力,获得家族长老的关注和认可。', '第4章 异火初现\n萧炎得知“异火”的存在,决定踏上寻找异火的旅程。'] 162 | 163 | changes = get_chunk_changes(source_chunks, target_chunks) 164 | for change in changes: 165 | print(f"Source chunks {change[0]}:{change[1]} -> Target chunks {change[2]}:{change[3]}") 166 | 167 | 168 | for change in changes: 169 | print('-' * 20) 170 | print(f"{''.join(source_chunks[change[0]:change[1]])} -> {''.join(target_chunks[change[2]:change[3]])}") 171 | 172 | if __name__ == "__main__": 173 | test_get_chunk_changes() -------------------------------------------------------------------------------- /core/draft_writer.py: -------------------------------------------------------------------------------- 1 | from core.writer_utils import KeyPointMsg 2 | from core.writer import Writer 3 | 4 | from prompts.创作正文.prompt import main as prompt_draft 5 | from prompts.提炼.prompt import main as prompt_summary 6 | 7 | 8 | class DraftWriter(Writer): 9 | def __init__(self, xy_pairs, global_context, model=None, sub_model=None, x_chunk_length=500, y_chunk_length=1000, max_thread_num=5): 10 | super().__init__(xy_pairs, global_context, model, sub_model, x_chunk_length=x_chunk_length, y_chunk_length=y_chunk_length, max_thread_num=max_thread_num) 11 | 12 | def write(self, user_prompt, pair_span=None): 13 | target_chunk = self.get_chunk(pair_span=pair_span) 14 | if not target_chunk.x_chunk: 15 | raise Exception("需要提供剧情。") 16 | if len(target_chunk.x_chunk) <= 5: 17 | raise Exception("剧情不能少于5个字。") 18 | 19 | chunks = self.get_chunks(pair_span) 20 | 21 | yield from self.batch_write_apply_text(chunks, prompt_draft, user_prompt) 22 | 23 | def summary(self, pair_span=None): 24 | target_chunk = self.get_chunk(pair_span=pair_span) 25 | if not target_chunk.y_chunk: 26 | raise Exception("没有正文需要总结。") 27 | if len(target_chunk.y_chunk) <= 5: 28 | raise Exception("需要总结的正文不能少于5个字。") 29 | 30 | # 先分割为更小的块,这样get_chunks才能正常工作 31 | new_target_chunk = self.map_text_wo_llm(target_chunk) 32 | self.apply_chunks([target_chunk], [new_target_chunk]) 33 | chunk_span = self.get_chunk_pair_span(new_target_chunk) 34 | 35 | chunks = self.get_chunks(chunk_span, context_length_ratio=0) 36 | 37 | yield from self.batch_write_apply_text(chunks, prompt_summary, "提炼剧情") 38 | 39 | def split_into_chapters(self): 40 | pass 41 | 42 | def get_model(self): 43 | return self.model 44 | 45 | def get_sub_model(self): 46 | return self.sub_model 47 | -------------------------------------------------------------------------------- /core/frontend_copy.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | 3 | enable_copy_js = """ 4 | 19 | """ 20 | 21 | def on_copy(fn, inputs, outputs): 22 | copy_textbox = gr.Textbox(elem_id="copy_textbox", visible=False) 23 | return copy_textbox.change(fn, [copy_textbox] + inputs, outputs) 24 | 25 | 26 | # with gr.Blocks(head=enable_copy_js) as demo: 27 | # gr.Markdown("Hello\nTest Copy") 28 | # copy_textbox = gr.Textbox(elem_id="copy_textbox", visible=False) 29 | 30 | # def copy_handle(text): 31 | # gr.Info(text) 32 | 33 | # copy_textbox.change(copy_handle, copy_textbox) 34 | 35 | # demo.launch() -------------------------------------------------------------------------------- /core/outline_writer.py: -------------------------------------------------------------------------------- 1 | from core.parser_utils import parse_chapters 2 | from core.writer_utils import KeyPointMsg 3 | from core.writer import Writer 4 | 5 | from prompts.创作章节.prompt import main as prompt_outline 6 | from prompts.提炼.prompt import main as prompt_summary 7 | 8 | class OutlineWriter(Writer): 9 | def __init__(self, xy_pairs, global_context, model=None, sub_model=None, x_chunk_length=2_000, y_chunk_length=2_000, max_thread_num=5): 10 | super().__init__(xy_pairs, global_context, model, sub_model, x_chunk_length=x_chunk_length, y_chunk_length=y_chunk_length, max_thread_num=max_thread_num) 11 | 12 | def write(self, user_prompt, pair_span=None): 13 | target_chunk = self.get_chunk(pair_span=pair_span) 14 | 15 | if not self.global_context.get("summary", ''): 16 | raise Exception("需要提供小说简介。") 17 | 18 | if not target_chunk.y_chunk.strip(): 19 | if not self.y.strip(): 20 | chunks = [target_chunk, ] 21 | else: 22 | raise Exception("选中进行创作的内容不能为空,考虑随便填写一些占位的字。") 23 | else: 24 | chunks = self.get_chunks(pair_span) 25 | 26 | new_chunks = yield from self.batch_yield( 27 | [self.write_text(e, prompt_outline, user_prompt) for e in chunks], 28 | chunks, prompt_name='创作文本') 29 | 30 | results = yield from self.batch_split_chapters(new_chunks) 31 | 32 | new_chunks2 = [e[0] for e in results] 33 | 34 | self.apply_chunks(chunks, new_chunks2) 35 | 36 | def split_chapters(self, chunk): 37 | if False: yield # 将此函数变为生成器函数 38 | 39 | assert chunk.x_chunk == '', 'chunk.x_chunk不为空' 40 | chapter_titles, chapter_contents = parse_chapters(chunk.y_chunk) 41 | new_xy_pairs = self.construct_xy_pairs(chapter_titles, chapter_contents) 42 | 43 | return chunk.edit(text_pairs=new_xy_pairs), True, '' 44 | 45 | def construct_xy_pairs(self, chapter_titles, chapter_contents): 46 | return [('', f"{title[0]} {title[1]}\n{content}") for title, content in zip(chapter_titles, chapter_contents)] 47 | 48 | def batch_split_chapters(self, chunks): 49 | results = yield from self.batch_yield( 50 | [self.split_chapters(e) for e in chunks], chunks, prompt_name='划分章节') 51 | return results 52 | 53 | def summary(self): 54 | target_chunk = self.get_chunk(pair_span=(0, len(self.xy_pairs))) 55 | if not target_chunk.y_chunk: 56 | raise Exception("没有章节需要总结。") 57 | if len(target_chunk.y_chunk) <= 5: 58 | raise Exception("需要总结的章节不能少于5个字。") 59 | 60 | if len(target_chunk.y_chunk) > 2000: 61 | y = self._truncate_chunk(target_chunk.y_chunk) 62 | else: 63 | y = target_chunk.y_chunk 64 | 65 | result = yield from prompt_summary(self.model, "提炼大纲", y=y) 66 | 67 | self.global_context['outline'] = result['text'] 68 | 69 | def get_model(self): 70 | return self.model 71 | 72 | def get_sub_model(self): 73 | return self.sub_model 74 | 75 | def _truncate_chunk(self, text, chunk_size=100, keep_chunks=20): 76 | """Truncate chunk content by keeping evenly spaced sections""" 77 | if len(text) <= 2000: 78 | return text 79 | 80 | # Split into chunks of chunk_size 81 | chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)] 82 | 83 | # Select evenly spaced chunks 84 | step = len(chunks) // keep_chunks 85 | selected_chunks = chunks[::step][:keep_chunks] 86 | new_content = '...'.join(selected_chunks) 87 | return new_content 88 | -------------------------------------------------------------------------------- /core/parser_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def parse_chapters(content): 5 | # Single pattern to capture: full chapter number (第X章), title, and content 6 | pattern = r'(第[零一二三四五六七八九十百千万亿0123456789.-]+章)([^\n]*)\n*([\s\S]*?)(?=第[零一二三四五六七八九十百千万亿0123456789.-]+章|$)' 7 | matches = re.findall(pattern, content) 8 | 9 | # Unpack directly into separate lists using zip 10 | chapter_titles, title_names, chapter_contents = zip(*[ 11 | (index, name.strip(), content.strip()) 12 | for index, name, content in matches 13 | ]) if matches else ([], [], []) 14 | 15 | return list(zip(chapter_titles, title_names)), list(chapter_contents) 16 | 17 | 18 | if __name__ == "__main__": 19 | test = """ 20 | 第1-1章 出世 21 | 主角张小凡出身贫寒,因天赋异禀被青云门收为弟子,开始修仙之路。 22 | 23 | 第2.1章 初入青云 24 | 25 | 张小凡在青云门中结识师兄弟,学习基础法术,逐渐适应修仙生活。 26 | 27 | 第3章 灵气初现 28 | 张小凡在一次意外中感受到天地灵气,修为有所提升。 29 | """ 30 | 31 | results = parse_chapters(test) 32 | print() 33 | -------------------------------------------------------------------------------- /core/plot_writer.py: -------------------------------------------------------------------------------- 1 | from core.writer_utils import KeyPointMsg 2 | from core.writer import Writer 3 | 4 | from prompts.创作剧情.prompt import main as prompt_plot 5 | from prompts.提炼.prompt import main as prompt_summary 6 | 7 | class PlotWriter(Writer): 8 | def __init__(self, xy_pairs, global_context, model=None, sub_model=None, x_chunk_length=200, y_chunk_length=1000, max_thread_num=5): 9 | super().__init__(xy_pairs, global_context, model, sub_model, x_chunk_length=x_chunk_length, y_chunk_length=y_chunk_length, max_thread_num=max_thread_num) 10 | 11 | def write(self, user_prompt, pair_span=None): 12 | target_chunk = self.get_chunk(pair_span=pair_span) 13 | 14 | if not self.global_context.get("chapter", ''): 15 | raise Exception("需要提供章节内容。") 16 | 17 | if not target_chunk.y_chunk.strip(): 18 | if not self.y.strip(): 19 | chunks = [target_chunk, ] 20 | else: 21 | raise Exception("选中进行创作的内容不能为空,考虑随便填写一些占位的字。") 22 | else: 23 | chunks = self.get_chunks(pair_span) 24 | 25 | new_chunks = yield from self.batch_yield( 26 | [self.write_text(e, prompt_plot, user_prompt) for e in chunks], 27 | chunks, prompt_name='创作文本') 28 | 29 | results = yield from self.batch_map_text(new_chunks) 30 | new_chunks2 = [e[0] for e in results] 31 | 32 | self.apply_chunks(chunks, new_chunks2) 33 | 34 | def summary(self): 35 | target_chunk = self.get_chunk(pair_span=(0, len(self.xy_pairs))) 36 | if not target_chunk.y_chunk: 37 | raise Exception("没有剧情需要总结。") 38 | if len(target_chunk.y_chunk) <= 5: 39 | raise Exception("需要总结的剧情不能少于5个字。") 40 | 41 | result = yield from prompt_summary(self.model, "提炼章节", y=target_chunk.y_chunk) 42 | 43 | self.global_context['chapter'] = result['text'] 44 | 45 | def get_model(self): 46 | return self.model 47 | 48 | def get_sub_model(self): 49 | return self.sub_model 50 | -------------------------------------------------------------------------------- /core/summary_novel.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from core.draft_writer import DraftWriter 3 | from core.plot_writer import PlotWriter 4 | from core.outline_writer import OutlineWriter 5 | from core.writer_utils import KeyPointMsg 6 | 7 | 8 | 9 | def summary_draft(model, sub_model, chapter_title, chapter_text): 10 | xy_pairs = [('', chapter_text)] 11 | 12 | dw = DraftWriter(xy_pairs, {}, model=model, sub_model=sub_model, x_chunk_length=500, y_chunk_length=1000) 13 | dw.max_thread_num = 1 # 每章的处理只采用一个线程 14 | 15 | generator = dw.summary(pair_span=(0, len(xy_pairs))) 16 | 17 | kp_msg_title = '' 18 | for kp_msg in generator: 19 | if isinstance(kp_msg, KeyPointMsg): 20 | # 如果要支持关键节点保存,需要计算一个编辑上的更改,然后在这里yield writer 21 | kp_msg_title = kp_msg.prompt_name 22 | continue 23 | else: 24 | chunk_list = kp_msg 25 | 26 | current_cost = 0 27 | currency_symbol = '' 28 | finished_chunk_num = 0 29 | chars_num = 0 30 | model = None 31 | for e in chunk_list: 32 | if e is None: continue 33 | finished_chunk_num += 1 34 | output, chunk = e 35 | if output is None: continue # 说明是map_text, 在第一次next就stop iteration了 36 | current_cost += output['response_msgs'].cost 37 | currency_symbol = output['response_msgs'].currency_symbol 38 | chars_num += len(output['response_msgs'].response) 39 | model = output['response_msgs'].model 40 | 41 | yield dict( 42 | progress_msg=f"[{chapter_title}] 提炼章节剧情 {kp_msg_title} 进度:{finished_chunk_num}/{len(chunk_list)} 已创作字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}", 43 | chars_num=chars_num, 44 | current_cost=current_cost, 45 | currency_symbol=currency_symbol, 46 | model=model 47 | ) 48 | 49 | return dw 50 | 51 | 52 | def summary_plot(model, sub_model, chapter_title, chapter_plot): 53 | xy_pairs = [('', chapter_plot)] 54 | 55 | pw = PlotWriter(xy_pairs, {}, model=model, sub_model=sub_model, x_chunk_length=500, y_chunk_length=1000) 56 | 57 | generator = pw.summary() 58 | 59 | for output in generator: 60 | current_cost = output['response_msgs'].cost 61 | currency_symbol = output['response_msgs'].currency_symbol 62 | chars_num = len(output['response_msgs'].response) 63 | yield dict( 64 | progress_msg=f"[{chapter_title}] 提炼章节大纲 已创作字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}", 65 | chars_num=chars_num, 66 | current_cost=current_cost, 67 | currency_symbol=currency_symbol, 68 | model=output['response_msgs'].model 69 | ) 70 | 71 | return pw 72 | 73 | def summary_chapters(model, sub_model, title, chapter_titles, chapter_content): 74 | ow = OutlineWriter([('', '')], {}, model=model, sub_model=sub_model, x_chunk_length=500, y_chunk_length=1000) 75 | ow.xy_pairs = ow.construct_xy_pairs(chapter_titles, chapter_content) 76 | 77 | generator = ow.summary() 78 | 79 | for output in generator: 80 | current_cost = output['response_msgs'].cost 81 | currency_symbol = output['response_msgs'].currency_symbol 82 | chars_num = len(output['response_msgs'].response) 83 | yield dict( 84 | progress_msg=f"[{title}] 提炼全书大纲 已创作字符:{chars_num} 已花费:{current_cost:.4f}{currency_symbol}", 85 | chars_num=chars_num, 86 | current_cost=current_cost, 87 | currency_symbol=currency_symbol, 88 | model=output['response_msgs'].model 89 | ) 90 | 91 | return ow 92 | 93 | 94 | -------------------------------------------------------------------------------- /core/writer_utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | # 定义了用于Wirter yield的数据类型,同时也是前端展示的“关键点”消息 4 | class KeyPointMsg(dict): 5 | def __init__(self, title='', subtitle='', prompt_name=''): 6 | super().__init__() 7 | if not title and not subtitle and prompt_name: 8 | pass 9 | elif title and subtitle and not prompt_name: 10 | pass 11 | else: 12 | raise ValueError('Either title and subtitle or prompt_name must be provided') 13 | 14 | self.update({ 15 | 'id': str(uuid.uuid4()), 16 | 'title': title, 17 | 'subtitle': subtitle, 18 | 'prompt_name': prompt_name, 19 | 'finished': False 20 | }) 21 | 22 | def set_finished(self): 23 | assert not self['finished'], 'finished flag is already set' 24 | self['finished'] = True 25 | return self # 返回self,方便链式调用 26 | 27 | def is_finished(self): 28 | return self['finished'] 29 | 30 | def is_prompt(self): 31 | return bool(self.prompt_name) 32 | 33 | def is_title(self): 34 | return bool(self.title) 35 | 36 | @property 37 | def id(self): 38 | return self['id'] 39 | 40 | @property 41 | def title(self): 42 | return self['title'] 43 | 44 | @property 45 | def subtitle(self): 46 | return self['subtitle'] 47 | 48 | @property 49 | def prompt_name(self): 50 | prompt_name = self['prompt_name'] 51 | if len(prompt_name) >= 10: 52 | return prompt_name[:10] + '...' 53 | return prompt_name 54 | 55 | 56 | import re 57 | from difflib import Differ 58 | 59 | # 后续考虑采用现成的库实现,目前逻辑过于繁琐,而且太慢了 60 | def detect_max_edit_span(a, b): 61 | diff = Differ().compare(a, b) 62 | 63 | l = 0 64 | r = 0 65 | flag_count_l = True 66 | 67 | for tag in diff: 68 | if tag.startswith(' '): 69 | if flag_count_l: 70 | l += 1 71 | else: 72 | r += 1 73 | else: 74 | flag_count_l = False 75 | r = 0 76 | 77 | return l, -r 78 | 79 | def split_text_by_separators(text, separators, keep_separators=True): 80 | """ 81 | 将文本按指定的分隔符分割为段落 82 | Args: 83 | text: 要分割的文本 84 | separators: 分隔符列表 85 | keep_separators: 是否在结果中保留分隔符,默认为True 86 | Returns: 87 | 包含分割后段落的列表 88 | """ 89 | pattern = f'({"|".join(map(re.escape, separators))}+)' 90 | chunks = re.split(pattern, text) 91 | 92 | paragraphs = [] 93 | current_para = [] 94 | 95 | for i in range(0, len(chunks), 2): 96 | content = chunks[i] 97 | separator = chunks[i + 1] if i + 1 < len(chunks) else '' 98 | 99 | current_para.append(content) 100 | if keep_separators and separator: 101 | current_para.append(separator) 102 | 103 | if content.strip(): 104 | paragraphs.append(''.join(current_para)) 105 | current_para = [] 106 | 107 | return paragraphs 108 | 109 | def split_text_into_paragraphs(text, keep_separators=True): 110 | return split_text_by_separators(text, ['\n'], keep_separators) 111 | 112 | def split_text_into_sentences(text, keep_separators=True): 113 | return split_text_by_separators(text, ['\n', '。', '?', '!', ';'], keep_separators) 114 | 115 | def run_and_echo_yield_func(func, *args, **kwargs): 116 | echo_text = "" 117 | all_messages = [] 118 | for messages in func(*args, **kwargs): 119 | all_messages.append(messages) 120 | new_echo_text = "\n".join(f"{msg['role']}:\n{msg['content']}" for msg in messages) 121 | if new_echo_text.startswith(echo_text): 122 | delta_echo_text = new_echo_text[len(echo_text):] 123 | else: 124 | echo_text = "" 125 | print('\n--------------------------------') 126 | delta_echo_text = new_echo_text 127 | 128 | print(delta_echo_text, end="") 129 | echo_text = echo_text + delta_echo_text 130 | return all_messages 131 | 132 | def run_yield_func(func, *args, **kwargs): 133 | gen = func(*args, **kwargs) 134 | try: 135 | while True: 136 | next(gen) 137 | except StopIteration as e: 138 | return e.value 139 | 140 | def split_text_into_chunks(text, max_chunk_size, min_chunk_n, min_chunk_size=1, max_chunk_n=1000): 141 | def split_paragraph(para): 142 | mid = len(para) // 2 143 | split_pattern = r'[。?;]' 144 | split_points = [m.end() for m in re.finditer(split_pattern, para)] 145 | 146 | if not split_points: 147 | raise Exception("没有找到分割点!") 148 | 149 | closest_point = min(split_points, key=lambda x: abs(x - mid)) 150 | if not para[:closest_point].strip() or not para[closest_point:].strip(): 151 | raise Exception("没有找到分割点!") 152 | 153 | return para[:closest_point], para[closest_point:] 154 | 155 | paragraphs = split_text_into_paragraphs(text) 156 | 157 | assert max_chunk_n >= 1, "max_chunk_n必须大于等于1" 158 | assert sum(len(p) for p in paragraphs) >= min_chunk_size, f"分割时,输入的文本长度小于要求的min_chunk_size:{min_chunk_size}" 159 | count = 0 # 防止死循环 160 | while len(paragraphs) > max_chunk_n or min(len(p) for p in paragraphs) < min_chunk_size: 161 | assert (count:=count+1) < 1000, "分割进入死循环!" 162 | 163 | # 找出相邻chunks中和最小的两个进行合并 164 | min_sum = float('inf') 165 | min_i = 0 166 | 167 | for i in range(len(paragraphs) - 1): 168 | curr_sum = len(paragraphs[i]) + len(paragraphs[i + 1]) 169 | if curr_sum < min_sum: 170 | min_sum = curr_sum 171 | min_i = i 172 | 173 | # 合并这两个chunks 174 | paragraphs[min_i:min_i + 2] = [''.join(paragraphs[min_i:min_i + 2])] 175 | 176 | while len(paragraphs) < min_chunk_n or max(len(p) for p in paragraphs) > max_chunk_size: 177 | assert (count:=count+1) < 1000, "分割进入死循环!" 178 | longest_para_i = max(range(len(paragraphs)), key=lambda i: len(paragraphs[i])) 179 | part1, part2 = split_paragraph(paragraphs[longest_para_i]) 180 | if len(part1) < min_chunk_size or len(part2) < min_chunk_size or len(paragraphs) + 1 > max_chunk_n: 181 | raise Exception("没有找到合适的分割点!") 182 | paragraphs[longest_para_i:longest_para_i+1] = [part1, part2] 183 | 184 | return paragraphs 185 | 186 | def test_split_text_into_chunks(): 187 | # Test case 1: Simple paragraph splitting 188 | text1 = "这是第一段。这是第二段。这是第三段。" 189 | result1 = split_text_into_chunks(text1, max_chunk_size=10, min_chunk_n=3) 190 | print("Test 1 result:", result1) 191 | assert len(result1) == 3, f"Expected 3 chunks, got {len(result1)}" 192 | 193 | 194 | # Test case 2: Long paragraph splitting 195 | text2 = "这是一个很长的段落,包含了很多句子。它应该被分割成多个小块。这里有一些标点符号,比如句号。还有问号?以及分号;这些都可以用来分割文本。" 196 | result2 = split_text_into_chunks(text2, max_chunk_size=20, min_chunk_n=4) 197 | print("Test 2 result:", result2) 198 | assert len(result2) >= 4, f"Expected at least 4 chunks, got {len(result2)}" 199 | assert all(len(chunk) <= 20 for chunk in result2), "Some chunks are longer than max_chunk_size" 200 | 201 | # Test case 3: Text with newlines 202 | text3 = "第一段。\n\n第二段。\n第三段。\n\n第四段很长,需要被分割。这是第四段的继续。" 203 | result3 = split_text_into_chunks(text3, max_chunk_size=15, min_chunk_n=5) 204 | print("Test 3 result:", result3) 205 | assert len(result3) >= 5, f"Expected at least 5 chunks, got {len(result3)}" 206 | assert all(len(chunk) <= 15 for chunk in result3), "Some chunks are longer than max_chunk_size" 207 | 208 | print("All tests passed!") 209 | 210 | if __name__ == "__main__": 211 | print(detect_max_edit_span("我吃西红柿", "我不喜欢吃西红柿")) 212 | print(detect_max_edit_span("我吃西红柿", "不喜欢吃西红柿")) 213 | print(detect_max_edit_span("我吃西红柿", "我不喜欢吃")) 214 | print(detect_max_edit_span("我吃西红柿", "你不喜欢吃西瓜")) 215 | 216 | test_split_text_into_chunks() 217 | -------------------------------------------------------------------------------- /custom/根据提纲创作正文/天蚕土豆风格.txt: -------------------------------------------------------------------------------- 1 | 你是一个网文大神作家,外号天蚕豌豆,擅长写玄幻网文,代表作有《斗破天空》,《舞动乾坤》,《我主宰》。 2 | 3 | 你的常用反派话语有: 4 | 此子断不可留,否则日后必成大患! 5 | 做事留一线,日后好相见。 6 | 一口鲜血夹杂着破碎的内脏喷出。 7 | 能把我逼到这种地步,你足以自傲了。 8 | 放眼XXX,你也算是凤毛麟角般的存在。 9 | 10 | 你的常用词语有: 11 | 黯然销魂、神出鬼没、格格不入、微不足道、窃窃私语、给我破、给我碎、摧枯拉朽、倒吸一口凉气、一脚踢开、旋即、苦笑、美眸、一拳、放眼、桀桀、负手而立、摧枯拉朽、黑袍老者、摸了摸鼻子、妮子、贝齿紧咬着红唇、幽怨、浊气、凤毛麟角、一声娇喝、恐怖如斯、纤纤玉手、头角峥嵘、桀桀桀、虎躯一震、苦笑一声、三千青丝 12 | 13 | 14 | 下面我会给你一段网文提纲,需要你对其进行润色或重写,输出网文正文。 15 | -------------------------------------------------------------------------------- /custom/根据提纲创作正文/对草稿进行润色.txt: -------------------------------------------------------------------------------- 1 | 你是一个网文作家,下面我会给你一段简陋粗略的网文草稿,需要你对其进行润色或重写,输出网文正文。 2 | 3 | 在创作的过程中,你需要注意以下事项: 4 | 1. 在草稿的基础上进行创作,不要过度延申,不要在结尾进行总结。 5 | 2. 对于草稿中内容,在正文中需要用小说家的口吻去描写,包括语言、行为、人物、环境描写等。 6 | 3. 对于草稿中缺失的部分,在正文中需要进行补全。 7 | 8 | -------------------------------------------------------------------------------- /docker_build.sh: -------------------------------------------------------------------------------- 1 | # 以下为多平台构建方式---------------------------------------------------------------------------------- 2 | export DOCKER_BUILDKIT=1 3 | 4 | docker buildx rm mybuilder 2>/dev/null || true 5 | # 这里ip为docker0的ip,通过ifconfig查看,配置了本地代理,不要的可以删除 6 | docker buildx create --use --name mybuilder --driver-opt env.http_proxy="http://172.17.0.1:7890" --driver-opt env.https_proxy="http://172.17.0.1:7890" 7 | docker buildx inspect mybuilder --bootstrap 8 | docker buildx build --platform linux/amd64,linux/arm64 -t maoxiaoyuz/long-novel-gpt:latest . --push 9 | 10 | # 如果build出现错误:dial tcp 172.17.0.1:7890: connect: connection refused,则需要修改clash配置文件,使其监听0.0.0.0:7890 11 | # 具体来说,在clash配置文件中添加以下内容: 12 | # allow-lan: true 13 | # bind-address: 0.0.0.0 -------------------------------------------------------------------------------- /docker_run.sh: -------------------------------------------------------------------------------- 1 | # 构建并启动docker 2 | # docker build -t maoxiaoyuz/long-novel-gpt . 3 | docker build --network host --build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890 -t maoxiaoyuz/long-novel-gpt . 4 | docker tag maoxiaoyuz/long-novel-gpt maoxiaoyuz/long-novel-gpt:2.2 5 | docker tag maoxiaoyuz/long-novel-gpt maoxiaoyuz/long-novel-gpt:latest 6 | docker run -p 80:80 --env-file .env --add-host=host.docker.internal:host-gateway -d maoxiaoyuz/long-novel-gpt:latest -------------------------------------------------------------------------------- /frontend/Dockerfile.frontend: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | # 复制前端文件 4 | COPY index.html /usr/share/nginx/html/ 5 | COPY js/ /usr/share/nginx/html/js/ 6 | COPY styles/ /usr/share/nginx/html/styles/ 7 | COPY data/ /usr/share/nginx/html/data/ 8 | 9 | # 复制 nginx 配置 10 | COPY nginx.conf /etc/nginx/conf.d/default.conf 11 | 12 | EXPOSE 9999 13 | 14 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /frontend/data/examples.yaml: -------------------------------------------------------------------------------- 1 | summary_examples: 2 | - title: 玄幻小说-斗破苍穹 3 | subtitle: 斗气的世界 4 | x: 《斗破苍穹》故事发生在一个充满“斗气”的大陆上,讲述了名为萧炎的少年,年少时被视为废物,接着奋起抗争,凭借自身努力、高人指导、奇珍异宝的帮助,不断收集“异火”,持续提升丹药炼制水平和斗气修炼水平的故事。最后,主角萧炎不仅洗刷当年的耻辱,还一跃成为斗气大陆上的最强者——“斗帝”,成为该世界秩序守护者和最高权力拥有者。 5 | - title: 仙侠小说-诛仙 6 | subtitle: 天地不仁,以万物为刍狗 7 | x: 《诛仙》是道亦魔似佛,问天地不仁,是以情撼九天,一剑诛仙。神州浩土,广瀚无边,方今之世,修真练道尤盛,修炼之人数不胜数。正派借天地灵气,修仙御法。 8 | outline_examples: 9 | - title: 玄幻小说-斗破苍穹 10 | subtitle: 少年萧炎历经磨难,最终逆袭成为斗气大陆巅峰强者 11 | x: |- 12 | 少年萧炎天赋异禀却突然废材三年,后在药老指导下重获修炼天赋,并获得异火。他在乌坦城打败萧家情敌,踏上修炼之路。 13 | 萧炎进入迦南学院学习,结识萧媚、小医仙等伙伴。在学院内展露实力,获得云岚宗云韵关注,并与其建立特殊情谊。 14 | 为解决药老元神消散危机,萧炎深入魔兽山脉寻找主药七幻青灵涎。途中与云岚宗宗主云山结怨,并获得陨落心炎。 15 | 萧炎为营救云韵,闯入云岚宗与云山对决。药老出手相助,云山重伤逃遁,云岚宗实力大损。萧炎获得异火榜第三的净莲妖火。 16 | 萧炎前往加玛帝国都城,参加炼药师大会,获得冠军,成为六品炼药师。期间揭露了欺师灭祖的韩枫真面目。 17 | 为寻求自身突破,萧炎进入空间虫洞,来到中州地区。在这里认识美杜莎女王,并助其摆脱诅咒,获得其芳心。 18 | 萧炎在中州与魂殿势力产生冲突,得知药老与魂殿纠葛。他陆续集齐多种异火,实力大增,并培养出强大势力。 19 | 萧炎率领星陨阁与魂殿展开大战,救出药老,并发现魂天帝的阴谋。他联合各方势力,最终击败魂天帝,成为斗气大陆最强者。 20 | 在统一斗气大陆后,萧炎带领人类对抗虚无吞炎,拯救世界。他突破至九星斗圣,与薰儿、美杜莎等道侣共同守护斗气大陆。 21 | - title: 仙侠小说-诛仙 22 | subtitle: 出身平凡的张小凡在正魔两道之间挣扎,经历爱恨情仇 23 | x: |- 24 | 张小凡在草庙村惨遭灭村后,被青云门收为弟子。 25 | 在青云门中他结识了一众同门,与林惊羽等人成为好友。 26 | 他初露修炼天赋,但由于天音功法无法击败,屡屡受挫,引发内心迷惘。 27 | 张小凡在青云门大试中遇到碧瑶,一名魔教少女。 28 | 两人产生复杂情感,碧瑶的性格和出身让张小凡对正魔之间的界限产生质疑。 29 | 他开始频繁接触魔教,知晓其内部复杂局势。 30 | 为了得到神器诛仙剑,正魔各派展开激战。 31 | 张小凡被迫协助正派争夺,亲历正魔冲突的惨烈。 32 | 碧瑶为保护张小凡而身陷险境,最后以性命施展痴情咒救得张小凡,引起巨大悲剧。 33 | 因碧瑶的牺牲,张小凡心灰意冷,离开青云门,投身魔教。 34 | 他改名鬼厉,迅速崛起并成为魔教中坚力量,在正派和魔教间周旋,寻找复活碧瑶的方法。 35 | 在鬼厉统领下,魔教势力日益强大,引发新一轮正魔交战。 36 | 张小凡于过程中不断反思人性和正义的复杂性,并逐渐找回自我。 37 | 最终大战中,他放下仇恨,维护两派和平。 38 | 正魔大战后,张小凡选择归隐,碧瑶虽未复活但留下幽魂陪伴左右。 39 | 各派经过历劫,纷纷反思往昔恩怨,以更加宽容姿态维系江湖的平衡与和谐。 40 | 41 | plot_examples: 42 | - title: 玄幻小说-斗破苍穹 43 | subtitle: 佛怒火莲初现 44 | x: |- 45 | 这个疯狂的念头出现后,萧炎无法摆脱,虽然觉得恐怖。 46 | 萧炎忍不住对这个念头着迷,觉得如果成功,威力可能不输给焰分噬浪尺。 47 | 旁边的海波东以为他放弃了,心里松了口气,觉得不值得为了青鳞冒险。 48 | 对面的八翼黑蛇皇甩动尾巴,带起强风,显露出恐怖的力量。 49 | 八翼黑蛇皇以为萧炎两人放弃了,嘲讽他们白费力气。 50 | 他低声猜测绿蛮已经安全,觉得自己的任务完成了。 51 | 八翼黑蛇皇嘲讽萧炎和海波东,称他们不敢再来找自己。 52 | 他谨慎地后退,盯着他们,不想露出背部。 53 | 萧炎挣扎后决定行动,伸出双手,令人疑惑。 54 | 八翼黑蛇皇冷笑,认为萧炎无法发挥异火的力量。 55 | 萧炎不理会嘲讽,双手展现出森白火焰。 56 | 白色火焰翻腾,显示出凶猛的能量。 57 | 八翼黑蛇皇盯着萧炎,怀疑他的异火无法发挥威力。 58 | 当萧炎右手出现青色火焰,八翼黑蛇皇震惊。 59 | 他惊骇地质问萧炎为何拥有两种异火。 60 | 海波东也震惊于萧炎同时拥有两种异火。 61 | 海波东觉得两种异火共存必然会引发危险。 62 | 他不理解萧炎为何能这么做,但能量因子变得暴躁。 63 | 海波东猜测萧炎的动机,注意到他的疯狂笑意。 64 | 海波东感到不安,动用斗气保护自己。 65 | 八翼黑蛇皇显然被两种异火打击,感到愤怒。 66 | 萧炎无视八翼黑蛇皇的谴责,注意自己的火焰。 67 | 他决定进行疯狂的实验,将两种异火融合。 68 | 海波东和八翼黑蛇皇大骂萧炎疯狂。 69 | 他们远离萧炎,担心实验失败的后果。 70 | 海波东无奈地骂萧炎不顾后果,觉得成功不可能。 71 | 萧炎的疯狂念头认为焚诀可融合多种异火。 72 | 他认为融合后的力量将更加强大。 73 | 这实验虽然危险,但若成功,将成为恐怖杀技。 74 | 萧炎认为这将是自己创造的独特斗技。 75 | 他的双手颤抖着,开始尝试融合火焰。 76 | 火苗接触时,萧炎手受伤,极为痛苦。 77 | 他忍耐着剧痛,双眼展现出火焰的异色。 78 | 萧炎全力压缩火焰,尽管随时可能爆炸。 79 | 八翼黑蛇皇留意着萧炎,希望看到他失败。 80 | 火焰逐渐狂暴,萧炎强忍痛苦继续实验。 81 | 海波东急忙提醒萧炎停止,但无济于事。 82 | 八翼黑蛇皇幸灾乐祸,觉得萧炎自取灭亡。 83 | 萧炎进入沉思状态,继续尝试融合。 84 | 在片刻后,他收敛精神,全神贯注于火焰中。 85 | 随着萧炎的努力,火焰变得安静。 86 | 火焰凝聚成青白莲座,海波东和蛇皇震撼。 87 | 萧炎察觉变化,低声自语“成功了么?” 88 | 虚弱的他将莲座投向八翼黑蛇皇。 89 | 莲座似乎无声无息,但威力惊人。 90 | 它爆炸成巨大的能量波,摧毁周围。 91 | 远处两个强者感受到这场可怕的爆炸。 92 | - title: 仙侠小说-幽冥仙途 93 | subtitle: 初见阴散人 94 | x: |- 95 | 李珣担心回山后被师门发现,不知如何解释。 96 | 担心师门如何看待他。 97 | 这些事李珣无法面对。 98 | 他害怕被师门找到,内心充满负罪感。 99 | 决定离开躲避,认为血魇的事不急。 100 | 为了躲避各方势力,他选择西城门离开。 101 | 他低调出城,计划出城后御剑飞行。 102 | 南城恢复人气,李珣的打扮不引人注目。 103 | 他用步法快速走到西城门,天色渐晚。 104 | 他计划出城后找个地方御剑飞行。 105 | 出城顺利,他松了口气,快速离开城门。 106 | 他寻找僻静处准备御剑。 107 | 一个神秘的女冠叫住了他。 108 | 李珣感到紧张,转头看向女冠。 109 | 女冠的衣着打扮像个道士。 110 | 李珣勉强微笑,却感到不安。 111 | 他感觉自己被女冠看穿,很不舒服。 112 | 他面对女冠说不出话来。 113 | 女冠问他是明心剑宗的弟子。 114 | 李珣点头承认,不敢反问女冠来历。 115 | 女冠问他来此的目的。 116 | 李珣谎称自己不知道。 117 | 他装出可怜的样子,让谎言显得真实。 118 | 女冠进一步询问他的心事。 119 | 李珣装作犹豫,说不出话。 120 | 女冠鼓励他说出难处。 121 | 李珣哭诉自己不想修道了。 122 | 他编造了在天都峰被妖凤吓跑的谎言。 123 | 他隐瞒了犯师、求饶、血魇等关键情节。 124 | 他利用天都峰事件的公开信息增加谎言的可信度。 125 | 他认为女冠不是正道人士。 126 | 他把自己塑造成胆小鬼降低女冠的怀疑。 127 | 他哭诉并讲述了编造的故事。 128 | 女冠相信了他的谎言。 129 | 李珣听天由命,等待女冠的决定。 130 | 女冠对李珣的性格表示欣赏。 131 | 女冠提出让李珣做她的弟子。 132 | 李珣假装犹豫后答应了。 133 | 李珣询问女冠的名讳。 134 | 女冠露出真容,气质非凡。 135 | 她的容貌美丽,但又透着阴森之气。 136 | 李珣被她的眼神震慑。 137 | 女冠自称阴散人。 -------------------------------------------------------------------------------- /frontend/docker_run.sh: -------------------------------------------------------------------------------- 1 | # 单独构建并运行前端,将访问宿主机上7869端口的后端服务 2 | docker rm -f $(docker ps -a | grep lngpt-frontend | awk '{print $1}') 2>/dev/null || true 3 | docker build -t lngpt-frontend -f Dockerfile.frontend . 4 | docker run -p 9999:9999 --add-host=host.docker.internal:host-gateway -d lngpt-frontend -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Long-Novel-GPT 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |

Long-Novel-GPT V2.2

22 |

AI一键生成长篇小说

23 |
24 |
25 |
26 | 33 |
34 |
选择小说
35 |
创作章节
36 |
创作剧情
37 |
创作正文
38 |
设置
39 |
40 |
41 |
42 |
43 | 55 |
56 |
57 |
58 |
59 |
60 | 61 | 62 | 66 | 80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 |

使用指南

88 | 91 |
92 |
93 |
94 |

快捷操作

95 |
    96 |
  • 单击 选中需要创作的文本块
  • 97 |
  • Ctrl + 点击 多选文本块进行批量操作
  • 98 |
  • Shift + 点击 选择连续的文本块
  • 99 |
  • 点击可以折叠使用指南和创作示例
  • 100 |
101 |
102 |
103 |

创作提示

104 |
    105 |
  • 选择更小的窗口更有利于扩充内容
  • 106 |
  • 可以自行输入提示词,提示词越精确,生成的内容越符合预期
  • 107 |
  • 使用右侧下拉菜单切换不同的 AI 模型和窗口大小
  • 108 |
109 |
110 |
111 |

注意事项

112 |
    113 |
  • 在线Demo限制了最大线程数,只有5个窗口可以同时创作
  • 114 |
  • 在设置中可以更改模型
  • 115 |
116 |
117 |
118 |
119 |
120 |
121 |

创作示例

122 | 125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | 小说简介 134 | 135 |
136 |
137 |
138 |
139 | 章节 140 | 141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /frontend/js/chat_messages.js: -------------------------------------------------------------------------------- 1 | import { copyToClipboard } from './copy_utils.js'; 2 | import { showToast } from './utils.js'; 3 | 4 | // Global variable to store current messages 5 | let currentMessages = []; 6 | 7 | export class ChatMessagesUI { 8 | constructor() { 9 | this.modal = this.createModal(); 10 | this.messageContainer = this.modal.querySelector('.chat-messages'); 11 | this.setupEventListeners(); 12 | } 13 | 14 | createModal() { 15 | const modal = document.createElement('div'); 16 | modal.className = 'chat-modal'; 17 | modal.innerHTML = ` 18 |
19 |
20 |

Prompt Messages

21 |
22 | 23 | 24 |
25 |
26 |
27 |
28 | `; 29 | document.body.appendChild(modal); 30 | return modal; 31 | } 32 | 33 | setupEventListeners() { 34 | const closeBtn = this.modal.querySelector('.close-btn'); 35 | closeBtn.addEventListener('click', () => this.hide()); 36 | 37 | const copyBtn = this.modal.querySelector('.copy-btn'); 38 | copyBtn.addEventListener('click', () => this.copyMessages()); 39 | 40 | // Close modal when clicking outside 41 | this.modal.addEventListener('click', (e) => { 42 | if (e.target === this.modal) { 43 | this.hide(); 44 | } 45 | }); 46 | } 47 | 48 | copyMessages() { 49 | const text = currentMessages.map(msg => `${msg.role}:\n${msg.content}`).join('\n\n'); 50 | copyToClipboard( 51 | text, 52 | () => showToast('复制成功', 'success'), 53 | () => showToast('复制失败', 'error') 54 | ); 55 | } 56 | 57 | show(messages) { 58 | currentMessages = messages; 59 | this.messageContainer.innerHTML = ''; 60 | messages.forEach(msg => { 61 | const messageEl = document.createElement('div'); 62 | messageEl.className = `chat-message ${msg.role}`; 63 | 64 | const contentEl = document.createElement('div'); 65 | contentEl.className = 'message-content'; 66 | contentEl.textContent = msg.content; 67 | 68 | const roleEl = document.createElement('div'); 69 | roleEl.className = 'message-role'; 70 | roleEl.textContent = msg.role.charAt(0).toUpperCase() + msg.role.slice(1); 71 | 72 | messageEl.appendChild(roleEl); 73 | messageEl.appendChild(contentEl); 74 | this.messageContainer.appendChild(messageEl); 75 | }); 76 | 77 | this.modal.style.display = 'flex'; 78 | } 79 | 80 | hide() { 81 | this.modal.style.display = 'none'; 82 | } 83 | } 84 | 85 | // Test code 86 | const testMessages = [ 87 | { role: 'system', content: 'You are a helpful novel writing assistant.' }, 88 | { role: 'user', content: '请帮我写一个科幻小说的开头。' }, 89 | { role: 'assistant', content: '在2157年的一个寒冷清晨,太空站"织女"号的警报突然响起...' }, 90 | ]; 91 | 92 | // Create button for testing 93 | // const testButton = document.createElement('button'); 94 | // testButton.textContent = '查看Prompt'; 95 | // testButton.className = 'show-prompt-btn'; 96 | // document.querySelector('.prompt-actions').appendChild(testButton); 97 | 98 | // const chatUI = new ChatMessagesUI(); 99 | // testButton.addEventListener('click', () => { 100 | // chatUI.show(testMessages); 101 | // }); -------------------------------------------------------------------------------- /frontend/js/copy_utils.js: -------------------------------------------------------------------------------- 1 | export function fallbackCopyToClipboard(text) { 2 | const textArea = document.createElement('textarea'); 3 | textArea.value = text; 4 | 5 | // Ensure textarea is outside the viewport 6 | textArea.style.position = 'fixed'; 7 | textArea.style.left = '-999999px'; 8 | textArea.style.top = '-999999px'; 9 | 10 | document.body.appendChild(textArea); 11 | textArea.focus(); 12 | textArea.select(); 13 | 14 | try { 15 | document.execCommand('copy'); 16 | } catch (err) { 17 | console.error('Copy failed:', err); 18 | throw err; 19 | } 20 | 21 | document.body.removeChild(textArea); 22 | } 23 | 24 | export function copyToClipboard(text, onSuccess, onError) { 25 | if (navigator.clipboard) { 26 | navigator.clipboard.writeText(text) 27 | .then(() => onSuccess && onSuccess()) 28 | .catch(err => { 29 | console.error('Copy failed:', err); 30 | try { 31 | fallbackCopyToClipboard(text); 32 | onSuccess && onSuccess(); 33 | } catch (err) { 34 | onError && onError(); 35 | } 36 | }); 37 | } else { 38 | try { 39 | fallbackCopyToClipboard(text); 40 | onSuccess && onSuccess(); 41 | } catch (err) { 42 | console.error('Clipboard API not supported'); 43 | onError && onError(); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /frontend/js/settings.js: -------------------------------------------------------------------------------- 1 | import { showToast } from './utils.js'; 2 | 3 | let previousMode = null; 4 | let modelConfigs = null; 5 | 6 | // Create and append the settings popup HTML to the document 7 | function createSettingsPopup() { 8 | const overlay = document.createElement('div'); 9 | overlay.className = 'settings-overlay'; 10 | 11 | const popup = document.createElement('div'); 12 | popup.className = 'settings-popup'; 13 | 14 | // Get settings from localStorage 15 | const settings = JSON.parse(localStorage.getItem('settings') || '{}'); 16 | 17 | popup.innerHTML = ` 18 |
19 |
20 |

设置

21 |

配置系统参数和模型选择

22 |
23 | 24 |
25 |
26 |
27 |

系统参数

28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |

模型设置

39 |
40 | 41 |
42 | 43 | 44 |
45 |
46 |
47 | 48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | 58 | `; 59 | 60 | overlay.appendChild(popup); 61 | document.body.appendChild(overlay); 62 | 63 | // Add event listeners 64 | const closeBtn = popup.querySelector('.settings-close'); 65 | closeBtn.addEventListener('click', hideSettings); 66 | 67 | const saveBtn = popup.querySelector('.save-settings'); 68 | saveBtn.addEventListener('click', () => { 69 | saveSettings(); 70 | hideSettings(); 71 | }); 72 | 73 | // Add test button event listeners 74 | const testButtons = popup.querySelectorAll('.test-model-btn'); 75 | testButtons.forEach(btn => { 76 | btn.addEventListener('click', async () => { 77 | const selectId = btn.dataset.for; 78 | const select = document.getElementById(selectId); 79 | const selectedModel = select.value; 80 | 81 | if (!selectedModel) { 82 | showToast('请先选择一个模型', 'error'); 83 | return; 84 | } 85 | 86 | btn.disabled = true; 87 | btn.textContent = '测试中...'; 88 | 89 | try { 90 | const response = await fetch(`${window._env_?.SERVER_URL}/test_model`, { 91 | method: 'POST', 92 | headers: { 93 | 'Content-Type': 'application/json', 94 | }, 95 | body: JSON.stringify({ 96 | provider_model: selectedModel 97 | }) 98 | }); 99 | 100 | const result = await response.json(); 101 | 102 | if (result.success) { 103 | showToast('模型测试成功', 'success'); 104 | } else { 105 | showToast(`模型测试失败: ${result.error}`, 'error'); 106 | } 107 | } catch (error) { 108 | showToast(`测试请求失败: ${error.message}`, 'error'); 109 | } finally { 110 | btn.disabled = false; 111 | btn.textContent = '测试'; 112 | } 113 | }); 114 | }); 115 | 116 | overlay.addEventListener('click', (e) => { 117 | if (e.target === overlay) { 118 | hideSettings(); 119 | } 120 | }); 121 | 122 | return overlay; 123 | } 124 | 125 | export async function loadModelConfigs() { 126 | try { 127 | const response = await fetch(`${window._env_?.SERVER_URL}/setting`); 128 | const settings = await response.json(); 129 | modelConfigs = settings.models; 130 | 131 | // Initialize localStorage settings if not exists 132 | if (!localStorage.getItem('settings')) { 133 | localStorage.setItem('settings', JSON.stringify({ 134 | MAIN_MODEL: settings.MAIN_MODEL, 135 | SUB_MODEL: settings.SUB_MODEL, 136 | MAX_THREAD_NUM: settings.MAX_THREAD_NUM, 137 | MAX_NOVEL_SUMMARY_LENGTH: settings.MAX_NOVEL_SUMMARY_LENGTH 138 | })); 139 | } 140 | } catch (error) { 141 | console.error('Error loading settings:', error); 142 | showToast('加载设置失败', 'error'); 143 | } 144 | } 145 | 146 | function updateModelSelects() { 147 | const mainModelSelect = document.getElementById('defaultMainModel'); 148 | const subModelSelect = document.getElementById('defaultSubModel'); 149 | 150 | if (!mainModelSelect || !subModelSelect || !modelConfigs) return; 151 | 152 | mainModelSelect.innerHTML = ''; 153 | subModelSelect.innerHTML = ''; 154 | 155 | // Filter out special config keys and only process provider/models pairs 156 | Object.entries(modelConfigs).forEach(([provider, models]) => { 157 | models.forEach(model => { 158 | const option = document.createElement('option'); 159 | option.value = `${provider}/${model}`; 160 | option.textContent = `${provider}/${model}`; 161 | 162 | mainModelSelect.appendChild(option.cloneNode(true)); 163 | subModelSelect.appendChild(option.cloneNode(true)); 164 | }); 165 | }); 166 | } 167 | 168 | function loadCurrentSettings() { 169 | const settings = JSON.parse(localStorage.getItem('settings')); 170 | const mainModelSelect = document.getElementById('defaultMainModel'); 171 | const subModelSelect = document.getElementById('defaultSubModel'); 172 | 173 | if (mainModelSelect.options.length > 0) { 174 | mainModelSelect.value = settings.MAIN_MODEL; 175 | } 176 | if (subModelSelect.options.length > 0) { 177 | subModelSelect.value = settings.SUB_MODEL; 178 | } 179 | 180 | // Load max thread number and novel summary length 181 | document.getElementById('maxThreadNum').value = settings.MAX_THREAD_NUM; 182 | document.getElementById('maxNovelSummaryLength').value = settings.MAX_NOVEL_SUMMARY_LENGTH; 183 | } 184 | 185 | function saveSettings() { 186 | const settings = { 187 | MAIN_MODEL: document.getElementById('defaultMainModel').value, 188 | SUB_MODEL: document.getElementById('defaultSubModel').value, 189 | MAX_THREAD_NUM: parseInt(document.getElementById('maxThreadNum').value), 190 | MAX_NOVEL_SUMMARY_LENGTH: parseInt(document.getElementById('maxNovelSummaryLength').value) 191 | }; 192 | 193 | localStorage.setItem('settings', JSON.stringify(settings)); 194 | showToast('设置已保存', 'success'); 195 | } 196 | 197 | export function showSettings(_previousMode) { 198 | // Store current mode before switching to settings 199 | previousMode = _previousMode; 200 | 201 | let overlay = document.querySelector('.settings-overlay'); 202 | if (!overlay) { 203 | overlay = createSettingsPopup(); 204 | updateModelSelects(); 205 | } 206 | 207 | loadCurrentSettings(); 208 | overlay.style.display = 'block'; 209 | } 210 | 211 | function hideSettings() { 212 | const overlay = document.querySelector('.settings-overlay'); 213 | if (overlay) { 214 | overlay.style.display = 'none'; 215 | 216 | // Switch back to previous mode 217 | if (previousMode) { 218 | const previousTab = document.querySelector(`.mode-tab[data-value="${previousMode}"]`); 219 | if (previousTab) { 220 | previousTab.click(); 221 | } 222 | } 223 | } 224 | } -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 9999; 3 | server_name localhost; 4 | 5 | # Set maximum allowed request body size to 20MB 6 | client_max_body_size 20M; 7 | 8 | root /usr/share/nginx/html; 9 | index index.html; 10 | 11 | # 启用 gzip 压缩 12 | gzip on; 13 | gzip_vary on; 14 | gzip_proxied any; 15 | gzip_comp_level 6; 16 | gzip_min_length 1000; 17 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 18 | 19 | # 安全相关头部 20 | # add_header X-Frame-Options "SAMEORIGIN"; 21 | # add_header X-XSS-Protection "1; mode=block"; 22 | # add_header X-Content-Type-Options "nosniff"; 23 | # add_header Referrer-Policy "strict-origin-when-cross-origin"; 24 | # add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"; 25 | 26 | # 全局禁用缓存 27 | add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; 28 | expires -1; 29 | 30 | location / { 31 | try_files $uri $uri/ /index.html; 32 | } 33 | 34 | # 代理后端 API 请求 35 | location /api/ { 36 | # Also set client_max_body_size for this location 37 | client_max_body_size 20M; 38 | 39 | proxy_pass http://host.docker.internal:7869/; # 访问宿主机上的后端服务 40 | proxy_http_version 1.1; 41 | proxy_set_header Upgrade $http_upgrade; 42 | proxy_set_header Connection 'upgrade'; 43 | proxy_set_header Host $host; 44 | proxy_set_header X-Real-IP $remote_addr; 45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 46 | proxy_set_header X-Forwarded-Proto $scheme; 47 | proxy_cache_bypass $http_upgrade; 48 | 49 | # CORS 配置 50 | add_header 'Access-Control-Allow-Origin' '*'; 51 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 52 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; 53 | 54 | # 超时设置 55 | proxy_connect_timeout 60s; 56 | proxy_send_timeout 60s; 57 | proxy_read_timeout 60s; 58 | } 59 | } -------------------------------------------------------------------------------- /frontend/styles/bottom_bar.css: -------------------------------------------------------------------------------- 1 | .bottom-bar { 2 | position: fixed; 3 | bottom: 20px; 4 | left: 50%; 5 | transform: translateX(-50%); 6 | background-color: rgba(52, 152, 219, 0.9); 7 | color: white; 8 | padding: 8px 16px; 9 | border-radius: 8px; 10 | font-size: 14px; 11 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 12 | z-index: 1000; 13 | transition: opacity 0.3s ease; 14 | opacity: 0; 15 | pointer-events: none; 16 | } 17 | 18 | .bottom-bar.visible { 19 | opacity: 1; 20 | } 21 | 22 | .bottom-bar-content { 23 | display: inline-block; 24 | } -------------------------------------------------------------------------------- /frontend/styles/chat_messages.css: -------------------------------------------------------------------------------- 1 | .chat-modal { 2 | display: none; 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | z-index: 1000; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .chat-modal-content { 15 | background-color: white; 16 | padding: 20px; 17 | border-radius: 8px; 18 | width: 80%; 19 | max-width: 800px; 20 | max-height: 80vh; 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | 25 | .chat-modal-header { 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | margin-bottom: 20px; 30 | } 31 | 32 | .chat-modal-header h2 { 33 | margin: 0; 34 | color: #333; 35 | } 36 | 37 | .chat-modal-actions { 38 | display: flex; 39 | gap: 10px; 40 | align-items: center; 41 | } 42 | 43 | .chat-modal .copy-btn { 44 | padding: 4px 8px; 45 | background: #f0f2f5; 46 | color: #666; 47 | border: 1px solid #e0e0e0; 48 | border-radius: 4px; 49 | cursor: pointer; 50 | font-size: 12px; 51 | transition: all 0.2s ease; 52 | } 53 | 54 | .chat-modal .copy-btn:hover { 55 | background: #e4e6e9; 56 | color: #333; 57 | transform: translateY(-1px); 58 | border-color: #d0d0d0; 59 | } 60 | 61 | .close-btn { 62 | background: none; 63 | border: none; 64 | font-size: 24px; 65 | cursor: pointer; 66 | color: #666; 67 | } 68 | 69 | .close-btn:hover { 70 | color: #333; 71 | } 72 | 73 | .chat-messages { 74 | overflow-y: auto; 75 | padding: 10px; 76 | flex-grow: 1; 77 | max-height: calc(80vh - 100px); 78 | } 79 | 80 | .chat-message { 81 | margin-bottom: 20px; 82 | padding: 15px; 83 | border-radius: 8px; 84 | max-width: 100%; 85 | } 86 | 87 | .chat-message.system { 88 | background-color: #f0f0f0; 89 | border-left: 4px solid #666; 90 | } 91 | 92 | .chat-message.user { 93 | background-color: #e3f2fd; 94 | border-left: 4px solid #2196f3; 95 | } 96 | 97 | .chat-message.assistant { 98 | background-color: #f5f5f5; 99 | border-left: 4px solid #4caf50; 100 | } 101 | 102 | .message-role { 103 | font-weight: bold; 104 | margin-bottom: 5px; 105 | color: #666; 106 | } 107 | 108 | .message-content { 109 | white-space: pre-wrap; 110 | word-break: break-word; 111 | } 112 | 113 | .show-prompt-btn { 114 | background-color: #4caf50; 115 | color: white; 116 | border: none; 117 | padding: 8px 16px; 118 | border-radius: 4px; 119 | cursor: pointer; 120 | margin-left: 10px; 121 | } 122 | 123 | .show-prompt-btn:hover { 124 | background-color: #45a049; 125 | } -------------------------------------------------------------------------------- /frontend/styles/examples.css: -------------------------------------------------------------------------------- 1 | .examples-section { 2 | background: white; 3 | border-radius: 12px; 4 | padding: 15px 20px; 5 | box-shadow: 0 2px 15px rgba(0,0,0,0.05); 6 | margin: 0px 0px; 7 | transition: all 0.3s ease; 8 | } 9 | 10 | .examples-section h3 { 11 | color: #2c3e50; 12 | font-size: 16px; 13 | margin-bottom: 15px; 14 | font-weight: 600; 15 | } 16 | 17 | .examples-container { 18 | display: flex; 19 | gap: 15px; 20 | overflow-x: auto; 21 | padding: 2px 0 8px; 22 | scrollbar-width: thin; 23 | scrollbar-color: #3498db #f0f2f5; 24 | cursor: grab; 25 | user-select: none; 26 | } 27 | 28 | .examples-container:active { 29 | cursor: grabbing; 30 | } 31 | 32 | .examples-container::-webkit-scrollbar { 33 | height: 6px; 34 | } 35 | 36 | .examples-container::-webkit-scrollbar-track { 37 | background: #f0f2f5; 38 | border-radius: 3px; 39 | } 40 | 41 | .examples-container::-webkit-scrollbar-thumb { 42 | background: #3498db; 43 | border-radius: 3px; 44 | } 45 | 46 | .example-card { 47 | flex: 0 0 auto; 48 | width: 200px; 49 | padding: 12px; 50 | border: 1px solid #e0e0e0; 51 | border-radius: 8px; 52 | cursor: pointer; 53 | transition: all 0.3s ease; 54 | background: #f8f9fa; 55 | } 56 | 57 | .example-card:hover { 58 | transform: translateY(-2px); 59 | box-shadow: 0 4px 12px rgba(0,0,0,0.1); 60 | border-color: #3498db; 61 | background: white; 62 | } 63 | 64 | .example-card h4 { 65 | color: #3498db; 66 | font-size: 15px; 67 | margin-bottom: 6px; 68 | } 69 | 70 | .example-card p { 71 | color: #7f8c8d; 72 | font-size: 13px; 73 | margin: 0; 74 | } 75 | 76 | .examples-header { 77 | display: flex; 78 | justify-content: space-between; 79 | align-items: center; 80 | margin-bottom: 10px; 81 | } 82 | 83 | .examples-header h3 { 84 | margin: 0; 85 | } 86 | 87 | .toggle-examples { 88 | background: none; 89 | border: none; 90 | color: #3498db; 91 | cursor: pointer; 92 | padding: 5px; 93 | display: flex; 94 | align-items: center; 95 | justify-content: center; 96 | transition: all 0.3s ease; 97 | } 98 | 99 | .toggle-examples:hover { 100 | color: #2980b9; 101 | } 102 | 103 | .toggle-examples .toggle-icon { 104 | display: inline-block; 105 | transition: transform 0.3s ease; 106 | } 107 | 108 | /* 折叠状态的样式 */ 109 | .examples-section.collapsed { 110 | padding: 12px 20px; 111 | } 112 | 113 | .examples-section.collapsed .examples-container { 114 | display: none; 115 | } 116 | 117 | .examples-section.collapsed .toggle-icon { 118 | transform: rotate(-90deg); 119 | } 120 | 121 | .examples-section.collapsed .examples-header { 122 | margin-bottom: 0; 123 | } -------------------------------------------------------------------------------- /frontend/styles/guide.css: -------------------------------------------------------------------------------- 1 | .guide-section { 2 | background: white; 3 | border-radius: 12px; 4 | padding: 12px 20px; /* 减小上下内边距 */ 5 | box-shadow: 0 2px 15px rgba(0,0,0,0.05); 6 | margin: 0px 0px; /* 减小上下外边距 */ 7 | transition: all 0.3s ease; 8 | } 9 | 10 | .guide-header { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | margin-bottom: 8px; /* 减小下边距 */ 15 | } 16 | 17 | .guide-header h3 { 18 | color: #2c3e50; 19 | font-size: 16px; 20 | margin: 0; 21 | font-weight: 600; 22 | } 23 | 24 | .toggle-guide { 25 | background: none; 26 | border: none; 27 | color: #3498db; 28 | cursor: pointer; 29 | padding: 5px; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | transition: all 0.3s ease; 34 | } 35 | 36 | .toggle-guide:hover { 37 | color: #2980b9; 38 | } 39 | 40 | .toggle-guide .toggle-icon { 41 | display: inline-block; 42 | transition: transform 0.3s ease; 43 | } 44 | 45 | .guide-container { 46 | display: flex; 47 | gap: 15px; /* 减小间距 */ 48 | padding: 0 0 5px;/* 减小内边距 */ 49 | } 50 | 51 | .guide-item { 52 | flex: 1; 53 | padding: 12px; /* 减小内边距 */ 54 | background: #f8f9fa; 55 | border-radius: 8px; 56 | border: 1px solid #e0e0e0; 57 | } 58 | 59 | .guide-item h4 { 60 | color: #3498db; 61 | font-size: 15px; 62 | margin-bottom: 8px;/* 减小下边距 */ 63 | } 64 | 65 | .guide-item ul { 66 | list-style: none; 67 | padding: 0; 68 | margin: 0; 69 | } 70 | 71 | .guide-item li { 72 | color: #7f8c8d; 73 | font-size: 13px; 74 | margin-bottom: 6px; /* 减小列表项间距 */ 75 | line-height: 1.4;/* 减小行高 */ 76 | } 77 | 78 | .key-combo { 79 | background: #e8f4fc; 80 | padding: 2px 6px; 81 | border-radius: 4px; 82 | color: #3498db; 83 | font-weight: 500; 84 | } 85 | 86 | /* 折叠状态的样式 */ 87 | .guide-section.collapsed { 88 | padding: 10px 20px; /* 减小折叠状态的内边距 */ 89 | } 90 | 91 | .guide-section.collapsed .guide-container { 92 | display: none; 93 | } 94 | 95 | .guide-section.collapsed .toggle-icon { 96 | transform: rotate(-90deg); 97 | } 98 | 99 | .guide-section.collapsed .guide-header { 100 | margin-bottom: 0; 101 | } -------------------------------------------------------------------------------- /frontend/styles/novel_select.css: -------------------------------------------------------------------------------- 1 | .novel-select-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | z-index: 1000; 9 | display: none; 10 | } 11 | 12 | .novel-select-popup { 13 | position: fixed; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | background-color: white; 18 | padding: 30px; 19 | border-radius: 12px; 20 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 21 | z-index: 1001; 22 | width: 80%; 23 | max-width: 800px; 24 | max-height: 85vh; 25 | overflow-y: auto; 26 | } 27 | 28 | .novel-select-header { 29 | display: flex; 30 | justify-content: space-between; 31 | align-items: flex-start; 32 | margin-bottom: 30px; 33 | padding-bottom: 20px; 34 | border-bottom: 1px solid #eee; 35 | } 36 | 37 | .header-content h3 { 38 | margin: 0; 39 | font-size: 24px; 40 | color: #333; 41 | } 42 | 43 | .header-content .subtitle { 44 | margin: 8px 0 0; 45 | color: #666; 46 | font-size: 16px; 47 | } 48 | 49 | .novel-select-close { 50 | background: none; 51 | border: none; 52 | font-size: 24px; 53 | cursor: pointer; 54 | padding: 5px; 55 | color: #666; 56 | transition: color 0.3s; 57 | } 58 | 59 | .novel-select-close:hover { 60 | color: #333; 61 | } 62 | 63 | .section-title { 64 | margin: 0 0 15px 0; 65 | font-size: 18px; 66 | color: #444; 67 | } 68 | 69 | .novel-list-section { 70 | margin-bottom: 30px; 71 | max-height: 400px; 72 | overflow-y: auto; 73 | padding-right: 5px; 74 | } 75 | 76 | /* Add custom scrollbar styles for better appearance */ 77 | .novel-list-section::-webkit-scrollbar { 78 | width: 8px; 79 | } 80 | 81 | .novel-list-section::-webkit-scrollbar-track { 82 | background: #f1f1f1; 83 | border-radius: 4px; 84 | } 85 | 86 | .novel-list-section::-webkit-scrollbar-thumb { 87 | background: #c1c1c1; 88 | border-radius: 4px; 89 | } 90 | 91 | .novel-list-section::-webkit-scrollbar-thumb:hover { 92 | background: #a8a8a8; 93 | } 94 | 95 | .novel-list { 96 | display: grid; 97 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 98 | gap: 20px; 99 | padding-right: 5px; /* Add some padding to prevent content from touching scrollbar */ 100 | } 101 | 102 | .novel-item { 103 | border: 1px solid #e0e0e0; 104 | padding: 20px; 105 | border-radius: 8px; 106 | cursor: pointer; 107 | transition: all 0.3s ease; 108 | background-color: #f8f9fa; 109 | } 110 | 111 | .novel-item:hover { 112 | background-color: #fff; 113 | transform: translateY(-3px); 114 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 115 | } 116 | 117 | .novel-item h4 { 118 | margin: 0 0 12px 0; 119 | color: #333; 120 | font-size: 18px; 121 | } 122 | 123 | .novel-item p { 124 | margin: 0; 125 | color: #666; 126 | font-size: 14px; 127 | line-height: 1.5; 128 | } 129 | 130 | .file-import-section { 131 | padding: 25px; 132 | background-color: #f8f9fa; 133 | border-radius: 8px; 134 | text-align: center; 135 | } 136 | 137 | .import-btn { 138 | padding: 12px 24px; 139 | background-color: #4CAF50; 140 | color: white; 141 | border: none; 142 | border-radius: 6px; 143 | cursor: pointer; 144 | font-size: 16px; 145 | transition: all 0.3s; 146 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 147 | } 148 | 149 | .import-btn:hover { 150 | background-color: #45a049; 151 | transform: translateY(-1px); 152 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); 153 | } 154 | 155 | .progress-msg { 156 | margin-top: 15px; 157 | color: #666; 158 | font-size: 14px; 159 | } -------------------------------------------------------------------------------- /frontend/styles/settings.css: -------------------------------------------------------------------------------- 1 | .settings-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | z-index: 1000; 9 | display: none; 10 | } 11 | 12 | .settings-popup { 13 | position: fixed; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | background-color: white; 18 | padding: 30px; 19 | border-radius: 12px; 20 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 21 | z-index: 1001; 22 | width: 80%; 23 | max-width: 600px; 24 | max-height: 85vh; 25 | overflow-y: auto; 26 | } 27 | 28 | .settings-header { 29 | display: flex; 30 | justify-content: space-between; 31 | align-items: flex-start; 32 | margin-bottom: 30px; 33 | padding-bottom: 20px; 34 | border-bottom: 1px solid #eee; 35 | } 36 | 37 | .header-content h3 { 38 | margin: 0; 39 | font-size: 24px; 40 | color: #333; 41 | } 42 | 43 | .header-content .subtitle { 44 | margin: 8px 0 0; 45 | color: #666; 46 | font-size: 16px; 47 | } 48 | 49 | .settings-close { 50 | background: none; 51 | border: none; 52 | font-size: 24px; 53 | cursor: pointer; 54 | padding: 5px; 55 | color: #666; 56 | transition: color 0.3s; 57 | } 58 | 59 | .settings-close:hover { 60 | color: #333; 61 | } 62 | 63 | .settings-content { 64 | margin-bottom: 30px; 65 | } 66 | 67 | .settings-section { 68 | margin-bottom: 25px; 69 | } 70 | 71 | .settings-section h4 { 72 | margin: 0 0 15px 0; 73 | font-size: 18px; 74 | color: #444; 75 | } 76 | 77 | .setting-item { 78 | margin-bottom: 15px; 79 | display: flex; 80 | align-items: center; 81 | gap: 15px; 82 | } 83 | 84 | .setting-item label { 85 | min-width: 150px; 86 | color: #555; 87 | font-size: 14px; 88 | } 89 | 90 | .model-select-group { 91 | flex: 1; 92 | display: flex; 93 | gap: 10px; 94 | align-items: center; 95 | } 96 | 97 | .model-select-group select { 98 | flex: 1; 99 | padding: 8px 12px; 100 | border: 1px solid #ddd; 101 | border-radius: 6px; 102 | font-size: 14px; 103 | color: #333; 104 | transition: border-color 0.3s; 105 | } 106 | 107 | .test-model-btn { 108 | padding: 8px 16px; 109 | background-color: #2196F3; 110 | color: white; 111 | border: none; 112 | border-radius: 6px; 113 | cursor: pointer; 114 | font-size: 14px; 115 | transition: all 0.3s; 116 | white-space: nowrap; 117 | } 118 | 119 | .test-model-btn:hover { 120 | background-color: #1976D2; 121 | } 122 | 123 | .test-model-btn:disabled { 124 | background-color: #9E9E9E; 125 | cursor: not-allowed; 126 | } 127 | 128 | .setting-item input[type="number"] { 129 | flex: 1; 130 | padding: 8px 12px; 131 | border: 1px solid #ddd; 132 | border-radius: 6px; 133 | font-size: 14px; 134 | color: #333; 135 | transition: border-color 0.3s; 136 | } 137 | 138 | .setting-item input[type="number"]:focus, 139 | .setting-item select:focus { 140 | outline: none; 141 | border-color: #4CAF50; 142 | } 143 | 144 | .settings-footer { 145 | text-align: right; 146 | padding-top: 20px; 147 | border-top: 1px solid #eee; 148 | } 149 | 150 | .save-settings { 151 | padding: 10px 20px; 152 | background-color: #4CAF50; 153 | color: white; 154 | border: none; 155 | border-radius: 6px; 156 | cursor: pointer; 157 | font-size: 14px; 158 | transition: all 0.3s; 159 | } 160 | 161 | .save-settings:hover { 162 | background-color: #45a049; 163 | transform: translateY(-1px); 164 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 165 | } 166 | 167 | /* Custom scrollbar styles */ 168 | .settings-popup::-webkit-scrollbar { 169 | width: 8px; 170 | } 171 | 172 | .settings-popup::-webkit-scrollbar-track { 173 | background: #f1f1f1; 174 | border-radius: 4px; 175 | } 176 | 177 | .settings-popup::-webkit-scrollbar-thumb { 178 | background: #c1c1c1; 179 | border-radius: 4px; 180 | } 181 | 182 | .settings-popup::-webkit-scrollbar-thumb:hover { 183 | background: #a8a8a8; 184 | } -------------------------------------------------------------------------------- /frontend/styles/toast.css: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | position: fixed; 3 | bottom: 20px; 4 | right: 20px; 5 | z-index: 1000; 6 | } 7 | 8 | .toast-message { 9 | background: rgba(52, 152, 219, 0.95); 10 | color: white; 11 | padding: 12px 24px; 12 | border-radius: 8px; 13 | margin: 8px; 14 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 15 | font-size: 14px; 16 | display: flex; 17 | align-items: center; 18 | gap: 8px; 19 | opacity: 0; 20 | transform: translateY(20px); 21 | transition: all 0.3s ease; 22 | } 23 | 24 | .toast-message.show { 25 | opacity: 1; 26 | transform: translateY(0); 27 | } 28 | 29 | .toast-message.success { 30 | background: rgba(46, 204, 113, 0.95); 31 | } 32 | 33 | .toast-message.error { 34 | background: rgba(231, 76, 60, 0.95); 35 | } 36 | 37 | .toast-message.warning { 38 | background: rgba(241, 196, 15, 0.95); 39 | } 40 | 41 | .toast-icon { 42 | font-size: 16px; 43 | } 44 | -------------------------------------------------------------------------------- /frontend/styles/tooltip.css: -------------------------------------------------------------------------------- 1 | /* Tooltip styling */ 2 | .select-wrapper[title] { 3 | position: relative; 4 | } 5 | 6 | .select-wrapper[title]::after { 7 | content: attr(title); 8 | position: absolute; 9 | bottom: 100%; 10 | left: 50%; 11 | transform: translateX(-50%); 12 | padding: 5px 10px; 13 | background: rgba(44, 62, 80, 0.9); 14 | color: white; 15 | font-size: 12px; 16 | border-radius: 4px; 17 | white-space: nowrap; 18 | opacity: 0; 19 | visibility: hidden; 20 | transition: all 0.2s ease; 21 | margin-bottom: 5px; 22 | } 23 | 24 | .select-wrapper[title]:hover::after { 25 | opacity: 1; 26 | visibility: visible; 27 | } 28 | -------------------------------------------------------------------------------- /install.md: -------------------------------------------------------------------------------- 1 | # 一些已知的安装问题 2 | 3 | ## ModuleNotFoundError: No module named 'pyaudioop' 4 | 5 | 即使gradio安装成功,import gradio也可能出现这个错误,原因未知。不过可以通过安装这个库解决: 6 | 7 | ```bash 8 | pip install audioop-lts 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /llm_api/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Optional, Generator 2 | 3 | from .mongodb_cache import llm_api_cache 4 | from .baidu_api import stream_chat_with_wenxin, wenxin_model_config 5 | from .doubao_api import stream_chat_with_doubao, doubao_model_config 6 | from .chat_messages import ChatMessages 7 | from .openai_api import stream_chat_with_gpt, gpt_model_config 8 | from .zhipuai_api import stream_chat_with_zhipuai, zhipuai_model_config 9 | 10 | class ModelConfig(dict): 11 | def __init__(self, model: str, **options): 12 | super().__init__(**options) 13 | self['model'] = model 14 | self.validate() 15 | 16 | def validate(self): 17 | def check_key(provider, keys): 18 | for key in keys: 19 | if key not in self: 20 | raise ValueError(f"{provider}的API设置中未传入: {key}") 21 | elif not self[key].strip(): 22 | raise ValueError(f"{provider}的API设置中未配置: {key}") 23 | 24 | if self['model'] in wenxin_model_config: 25 | check_key('文心一言', ['ak', 'sk']) 26 | elif self['model'] in doubao_model_config: 27 | check_key('豆包', ['api_key', 'endpoint_id']) 28 | elif self['model'] in zhipuai_model_config: 29 | check_key('智谱AI', ['api_key']) 30 | elif self['model'] in gpt_model_config or True: 31 | # 其他模型名默认采用openai接口调用 32 | check_key('OpenAI', ['api_key']) 33 | 34 | if 'max_tokens' not in self: 35 | raise ValueError('ModelConfig未传入key: max_tokens') 36 | else: 37 | assert self['max_tokens'] <= 4_096, 'max_tokens最大为4096!' 38 | 39 | 40 | def get_api_keys(self) -> Dict[str, str]: 41 | return {k: v for k, v in self.items() if k not in ['model']} 42 | 43 | @llm_api_cache() 44 | def stream_chat(model_config: ModelConfig, messages: list, response_json=False) -> Generator: 45 | if isinstance(model_config, dict): 46 | model_config = ModelConfig(**model_config) 47 | 48 | model_config.validate() 49 | 50 | messages = ChatMessages(messages, model=model_config['model']) 51 | 52 | assert model_config['max_tokens'] <= 4096, 'max_tokens最大为4096!' 53 | 54 | if messages.count_message_tokens() > model_config['max_tokens']: 55 | raise Exception(f'请求的文本过长,超过最大tokens:{model_config["max_tokens"]}。') 56 | 57 | yield messages 58 | 59 | if model_config['model'] in wenxin_model_config: 60 | result = yield from stream_chat_with_wenxin( 61 | messages, 62 | model=model_config['model'], 63 | ak=model_config['ak'], 64 | sk=model_config['sk'], 65 | max_tokens=model_config['max_tokens'], 66 | response_json=response_json 67 | ) 68 | elif model_config['model'] in doubao_model_config: # doubao models 69 | result = yield from stream_chat_with_doubao( 70 | messages, 71 | model=model_config['model'], 72 | endpoint_id=model_config['endpoint_id'], 73 | api_key=model_config['api_key'], 74 | max_tokens=model_config['max_tokens'], 75 | response_json=response_json 76 | ) 77 | elif model_config['model'] in zhipuai_model_config: # zhipuai models 78 | result = yield from stream_chat_with_zhipuai( 79 | messages, 80 | model=model_config['model'], 81 | api_key=model_config['api_key'], 82 | max_tokens=model_config['max_tokens'], 83 | response_json=response_json 84 | ) 85 | elif model_config['model'] in gpt_model_config or True: # openai models或其他兼容openai接口的模型 86 | result = yield from stream_chat_with_gpt( 87 | messages, 88 | model=model_config['model'], 89 | api_key=model_config['api_key'], 90 | base_url=model_config.get('base_url'), 91 | proxies=model_config.get('proxies'), 92 | max_tokens=model_config['max_tokens'], 93 | response_json=response_json 94 | ) 95 | 96 | result.finished = True 97 | yield result 98 | 99 | return result 100 | 101 | def test_stream_chat(model_config: ModelConfig): 102 | messages = [{"role": "user", "content": "1+1=?直接输出答案即可:"}] 103 | for response in stream_chat(model_config, messages, use_cache=False): 104 | yield response.response 105 | 106 | return response 107 | 108 | # 导出必要的函数和配置 109 | __all__ = ['ChatMessages', 'stream_chat', 'wenxin_model_config', 'doubao_model_config', 'gpt_model_config', 'zhipuai_model_config', 'ModelConfig'] 110 | -------------------------------------------------------------------------------- /llm_api/baidu_api.py: -------------------------------------------------------------------------------- 1 | import qianfan 2 | from .chat_messages import ChatMessages 3 | 4 | # ak和sk获取:https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application 5 | 6 | # 价格:https://cloud.baidu.com/doc/WENXINWORKSHOP/s/hlrk4akp7 7 | 8 | wenxin_model_config = { 9 | "ERNIE-3.5-8K":{ 10 | "Pricing": (0.0008, 0.002), 11 | "currency_symbol": '¥', 12 | }, 13 | "ERNIE-4.0-8K":{ 14 | "Pricing": (0.03, 0.09), 15 | "currency_symbol": '¥', 16 | }, 17 | "ERNIE-Novel-8K":{ 18 | "Pricing": (0.04, 0.12), 19 | "currency_symbol": '¥', 20 | } 21 | } 22 | 23 | 24 | def stream_chat_with_wenxin(messages, model='ERNIE-Bot', response_json=False, ak=None, sk=None, max_tokens=6000): 25 | if ak is None or sk is None: 26 | raise Exception('未提供有效的 ak 和 sk!') 27 | 28 | client = qianfan.ChatCompletion(ak=ak, sk=sk) 29 | 30 | chatstream = client.do(model=model, 31 | system=messages[0]['content'] if messages[0]['role'] == 'system' else None, 32 | messages=messages if messages[0]['role'] != 'system' else messages[1:], 33 | stream=True, 34 | response_format='json_object' if response_json else 'text' 35 | ) 36 | 37 | messages.append({'role': 'assistant', 'content': ''}) 38 | content = '' 39 | for part in chatstream: 40 | content += part['body']['result'] or '' 41 | messages[-1]['content'] = content 42 | yield messages 43 | 44 | return messages 45 | 46 | 47 | if __name__ == '__main__': 48 | pass -------------------------------------------------------------------------------- /llm_api/chat_messages.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import re 3 | import json 4 | import os 5 | 6 | def count_characters(text): 7 | chinese_pattern = re.compile(r'[\u4e00-\u9fff]+') 8 | english_pattern = re.compile(r'[a-zA-Z]+') 9 | other_pattern = re.compile(r'[^\u4e00-\u9fffa-zA-Z]+') 10 | 11 | chinese_characters = chinese_pattern.findall(text) 12 | english_characters = english_pattern.findall(text) 13 | other_characters = other_pattern.findall(text) 14 | 15 | chinese_count = sum(len(char) for char in chinese_characters) 16 | english_count = sum(len(char) for char in english_characters) 17 | other_count = sum(len(char) for char in other_characters) 18 | 19 | return chinese_count, english_count, other_count 20 | 21 | 22 | model_config = {} 23 | 24 | 25 | model_prices = {} 26 | try: 27 | model_prices_path = os.path.join(os.path.dirname(__file__), 'model_prices.json') 28 | with open(model_prices_path, 'r') as f: 29 | model_prices = json.load(f) 30 | except Exception as e: 31 | print(f"Warning: Failed to load model_prices.json: {e}") 32 | 33 | class ChatMessages(list): 34 | def __init__(self, *args, **kwargs): 35 | super().__init__(*args) 36 | self.model = kwargs['model'] if 'model' in kwargs else None 37 | self.finished = False 38 | 39 | assert 'currency_symbol' not in kwargs 40 | 41 | if not model_config: 42 | from .baidu_api import wenxin_model_config 43 | from .doubao_api import doubao_model_config 44 | from .openai_api import gpt_model_config 45 | from .zhipuai_api import zhipuai_model_config 46 | model_config.update({**wenxin_model_config, **doubao_model_config, **gpt_model_config, **zhipuai_model_config}) 47 | 48 | def __getitem__(self, index): 49 | result = super().__getitem__(index) 50 | if isinstance(index, slice): 51 | return ChatMessages(result, model=self.model) 52 | return result 53 | 54 | def __add__(self, other): 55 | if isinstance(other, list): 56 | return ChatMessages(super().__add__(other), model=self.model) 57 | return NotImplemented 58 | 59 | def count_message_tokens(self): 60 | return self.get_estimated_tokens() 61 | 62 | def copy(self): 63 | return ChatMessages(self, model=self.model) 64 | 65 | def get_estimated_tokens(self): 66 | num_tokens = 0 67 | for message in self: 68 | for key, value in message.items(): 69 | chinese_count, english_count, other_count = count_characters(value) 70 | num_tokens += chinese_count // 2 + english_count // 5 + other_count // 2 71 | return num_tokens 72 | 73 | def get_prompt_messages_hash(self): 74 | # 转换为JSON字符串并创建哈希 75 | cache_string = json.dumps(self.prompt_messages, sort_keys=True) 76 | return hashlib.md5(cache_string.encode()).hexdigest() 77 | 78 | @property 79 | def cost(self): 80 | if len(self) == 0: 81 | return 0 82 | 83 | if self.model in model_config: 84 | return model_config[self.model]["Pricing"][0] * self[:-1].count_message_tokens() / 1_000 + model_config[self.model]["Pricing"][1] * self[-1:].count_message_tokens() / 1_000 85 | elif self.model in model_prices: 86 | return ( 87 | model_prices[self.model]["input_cost_per_token"] * self[:-1].count_message_tokens() + 88 | model_prices[self.model]["output_cost_per_token"] * self[-1:].count_message_tokens() 89 | ) 90 | return 0 91 | 92 | @property 93 | def response(self): 94 | return self[-1]['content'] if self[-1]['role'] == 'assistant' else '' 95 | 96 | @property 97 | def prompt_messages(self): 98 | return self[:-1] if self.response else self 99 | 100 | @property 101 | def currency_symbol(self): 102 | if self.model in model_config: 103 | return model_config[self.model]["currency_symbol"] 104 | else: 105 | return '$' 106 | 107 | @property 108 | def cost_info(self): 109 | formatted_cost = f"{self.cost:.7f}".rstrip('0').rstrip('.') 110 | return f"{self.model}: {formatted_cost}{self.currency_symbol}" 111 | 112 | def print(self): 113 | for message in self: 114 | print(f"{message['role']}".center(100, '-') + '\n') 115 | print(message['content']) 116 | print() 117 | -------------------------------------------------------------------------------- /llm_api/doubao_api.py: -------------------------------------------------------------------------------- 1 | from openai import OpenAI 2 | from .chat_messages import ChatMessages 3 | 4 | doubao_model_config = { 5 | "doubao-lite-32k":{ 6 | "Pricing": (0.0003, 0.0006), 7 | "currency_symbol": '¥', 8 | }, 9 | "doubao-lite-128k":{ 10 | "Pricing": (0.0008, 0.001), 11 | "currency_symbol": '¥', 12 | }, 13 | "doubao-pro-32k":{ 14 | "Pricing": (0.0008, 0.002), 15 | "currency_symbol": '¥', 16 | }, 17 | "doubao-pro-128k":{ 18 | "Pricing": (0.005, 0.009), 19 | "currency_symbol": '¥', 20 | }, 21 | } 22 | 23 | def stream_chat_with_doubao(messages, model='doubao-lite-32k', endpoint_id=None, response_json=False, api_key=None, max_tokens=32000): 24 | if api_key is None: 25 | raise Exception('未提供有效的 api_key!') 26 | if endpoint_id is None: 27 | raise Exception('未提供有效的 endpoint_id!') 28 | 29 | client = OpenAI( 30 | api_key=api_key, 31 | base_url="https://ark.cn-beijing.volces.com/api/v3", 32 | ) 33 | 34 | stream = client.chat.completions.create( 35 | model=endpoint_id, 36 | messages=messages, 37 | stream=True, 38 | response_format={ "type": "json_object" } if response_json else None 39 | ) 40 | 41 | messages.append({'role': 'assistant', 'content': ''}) 42 | content = '' 43 | for chunk in stream: 44 | if chunk.choices: 45 | delta_content = chunk.choices[0].delta.content or '' 46 | content += delta_content 47 | messages[-1]['content'] = content 48 | yield messages 49 | 50 | return messages 51 | 52 | if __name__ == '__main__': 53 | pass 54 | -------------------------------------------------------------------------------- /llm_api/mongodb_cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | import functools 3 | from typing import Generator, Any 4 | from pymongo import MongoClient 5 | import hashlib 6 | import json 7 | import datetime 8 | import random 9 | 10 | from config import ENABLE_MONOGODB, MONOGODB_DB_NAME, ENABLE_MONOGODB_CACHE, CACHE_REPLAY_SPEED, CACHE_REPLAY_MAX_DELAY 11 | 12 | from .chat_messages import ChatMessages 13 | from .mongodb_cost import record_api_cost, check_cost_limits 14 | from .mongodb_init import mongo_client as client 15 | 16 | def create_cache_key(func_name: str, args: tuple, kwargs: dict) -> str: 17 | """创建缓存键""" 18 | # 将参数转换为可序列化的格式 19 | cache_dict = { 20 | 'func_name': func_name, 21 | 'args': args, 22 | 'kwargs': kwargs 23 | } 24 | # 转换为JSON字符串并创建哈希 25 | cache_string = json.dumps(cache_dict, sort_keys=True) 26 | return hashlib.md5(cache_string.encode()).hexdigest() 27 | 28 | 29 | 30 | def llm_api_cache(): 31 | """MongoDB缓存装饰器""" 32 | db_name=MONOGODB_DB_NAME 33 | collection_name='stream_chat' 34 | 35 | def dummy_decorator(func): 36 | @functools.wraps(func) 37 | def wrapper(*args, **kwargs): 38 | # 移除 use_cache 参数,避免传递给原函数 39 | kwargs.pop('use_cache', None) 40 | return func(*args, **kwargs) 41 | return wrapper 42 | 43 | 44 | if not ENABLE_MONOGODB: 45 | return dummy_decorator 46 | 47 | def decorator(func): 48 | @functools.wraps(func) 49 | def wrapper(*args, **kwargs): 50 | check_cost_limits() 51 | 52 | use_cache = kwargs.pop('use_cache', True) # pop很重要 53 | 54 | if not ENABLE_MONOGODB_CACHE: 55 | use_cache = False 56 | 57 | db = client[db_name] 58 | collection = db[collection_name] 59 | 60 | # 创建缓存键 61 | cache_key = create_cache_key(func.__name__, args, kwargs) 62 | 63 | # 检查缓存 64 | if use_cache: 65 | cached_data = list(collection.aggregate([ 66 | {'$match': {'cache_key': cache_key}}, 67 | {'$sample': {'size': 1}} 68 | ])) 69 | cached_data = cached_data[0] if cached_data else None 70 | if cached_data: 71 | # 如果有缓存,yield缓存的结果 72 | messages = ChatMessages(cached_data['return_value']) 73 | messages.model = args[0]['model'] 74 | for item in cached_data['yields']: 75 | sacled_delay = min(item['delay'] / CACHE_REPLAY_SPEED, CACHE_REPLAY_MAX_DELAY) 76 | if sacled_delay > 0: time.sleep(sacled_delay) # 应用加速倍数 77 | else: continue 78 | if item['index'] > 0: 79 | yield messages.prompt_messages + [{'role': 'assistant', 'content': messages.response[:item['index']]}] 80 | else: 81 | yield messages.prompt_messages 82 | messages.finished = True 83 | yield messages 84 | return messages 85 | 86 | # 如果没有缓存,执行原始函数并记录结果 87 | yields_data = [] 88 | last_time = time.time() 89 | 90 | generator = func(*args, **kwargs) 91 | 92 | try: 93 | while True: 94 | current_time = time.time() 95 | value = next(generator) 96 | delay = current_time - last_time 97 | 98 | yields_data.append({ 99 | 'index': len(value.response), 100 | 'delay': delay 101 | }) 102 | 103 | last_time = current_time 104 | yield value 105 | 106 | except StopIteration as e: 107 | return_value = e.value 108 | 109 | # 记录API调用费用 110 | record_api_cost(return_value) 111 | 112 | # 存储到MongoDB 113 | cache_data = { 114 | 'created_at':datetime.datetime.now(), 115 | 'return_value': return_value, 116 | 'func_name': func.__name__, 117 | 'args': args, 118 | 'kwargs': kwargs, 119 | 'yields': yields_data, 120 | 'cache_key': cache_key, 121 | } 122 | collection.insert_one(cache_data) 123 | 124 | return return_value 125 | 126 | return wrapper 127 | return decorator 128 | -------------------------------------------------------------------------------- /llm_api/mongodb_cost.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from config import API_COST_LIMITS, MONOGODB_DB_NAME 4 | 5 | from .chat_messages import ChatMessages 6 | from .mongodb_init import mongo_client as client 7 | 8 | def record_api_cost(messages: ChatMessages): 9 | """记录API调用费用""" 10 | 11 | db = client[MONOGODB_DB_NAME] 12 | collection = db['api_cost'] 13 | 14 | cost_data = { 15 | 'created_at': datetime.datetime.now(), 16 | 'model': messages.model, 17 | 'cost': messages.cost, 18 | 'currency_symbol': messages.currency_symbol, 19 | 'input_tokens': messages[:-1].count_message_tokens(), 20 | 'output_tokens': messages[-1:].count_message_tokens(), 21 | 'total_tokens': messages.count_message_tokens() 22 | } 23 | collection.insert_one(cost_data) 24 | 25 | def get_model_cost_stats(start_date: datetime.datetime, end_date: datetime.datetime) -> list: 26 | """获取指定时间段内的模型调用费用统计""" 27 | pipeline = [ 28 | { 29 | '$match': { 30 | 'created_at': { 31 | '$gte': start_date, 32 | '$lte': end_date 33 | } 34 | } 35 | }, 36 | { 37 | '$group': { 38 | '_id': '$model', 39 | 'total_cost': { '$sum': '$cost' }, 40 | 'total_calls': { '$sum': 1 }, 41 | 'total_input_tokens': { '$sum': '$input_tokens' }, 42 | 'total_output_tokens': { '$sum': '$output_tokens' }, 43 | 'total_tokens': { '$sum': '$total_tokens' }, 44 | 'avg_cost_per_call': { '$avg': '$cost' }, 45 | 'currency_symbol': { '$first': '$currency_symbol' } 46 | } 47 | }, 48 | { 49 | '$project': { 50 | 'model': '$_id', 51 | 'total_cost': { '$round': ['$total_cost', 4] }, 52 | 'total_calls': 1, 53 | 'total_input_tokens': 1, 54 | 'total_output_tokens': 1, 55 | 'total_tokens': 1, 56 | 'avg_cost_per_call': { '$round': ['$avg_cost_per_call', 4] }, 57 | 'currency_symbol': 1, 58 | '_id': 0 59 | } 60 | }, 61 | { 62 | '$sort': { 'total_cost': -1 } 63 | } 64 | ] 65 | 66 | # 直接从 api_cost 集合查询数据 67 | db = client[MONOGODB_DB_NAME] 68 | collection = db['api_cost'] 69 | 70 | stats = list(collection.aggregate(pipeline)) 71 | return stats 72 | 73 | # 使用示例: 74 | def print_cost_report(days: int = 30, hours: int = 0): 75 | """打印最近N天的费用报告""" 76 | end_date = datetime.datetime.now() 77 | start_date = end_date - datetime.timedelta(days=days, hours=hours) 78 | 79 | stats = get_model_cost_stats(start_date, end_date) 80 | 81 | print(f"\n=== API Cost Report ({start_date.date()} to {end_date.date()}) ===") 82 | for model_stat in stats: 83 | print(f"\nModel: {model_stat['model']}") 84 | print(f"Total Cost: {model_stat['currency_symbol']}{model_stat['total_cost']:.4f}") 85 | print(f"Total Calls: {model_stat['total_calls']}") 86 | print(f"Total Tokens: {model_stat['total_tokens']:,}") 87 | print(f"Avg Cost/Call: {model_stat['currency_symbol']}{model_stat['avg_cost_per_call']:.4f}") 88 | 89 | def check_cost_limits() -> bool: 90 | """ 91 | 检查API调用费用是否超过限制 92 | 返回: 如果未超过限制返回True,否则返回False 93 | """ 94 | now = datetime.datetime.now() 95 | hour_ago = now - datetime.timedelta(hours=1) 96 | day_ago = now - datetime.timedelta(days=1) 97 | 98 | # 获取统计数据 99 | hour_stats = get_model_cost_stats(hour_ago, now) 100 | day_stats = get_model_cost_stats(day_ago, now) 101 | 102 | # 计算总费用并根据需要转换为人民币 103 | hour_total_rmb = sum( 104 | stat['total_cost'] * (API_COST_LIMITS['USD_TO_RMB_RATE'] if stat['currency_symbol'] == '$' else 1) 105 | for stat in hour_stats 106 | ) 107 | day_total_rmb = sum( 108 | stat['total_cost'] * (API_COST_LIMITS['USD_TO_RMB_RATE'] if stat['currency_symbol'] == '$' else 1) 109 | for stat in day_stats 110 | ) 111 | 112 | # 检查是否超过限制 113 | if hour_total_rmb >= API_COST_LIMITS['HOURLY_LIMIT_RMB']: 114 | print(f"警告:最近1小时API费用(¥{hour_total_rmb:.2f})超过限制(¥{API_COST_LIMITS['HOURLY_LIMIT_RMB']})") 115 | raise Exception("最近1小时内API调用费用超过设定上限!") 116 | 117 | if day_total_rmb >= API_COST_LIMITS['DAILY_LIMIT_RMB']: 118 | print(f"警告:最近24小时API费用(¥{day_total_rmb:.2f})超过限制(¥{API_COST_LIMITS['DAILY_LIMIT_RMB']})") 119 | raise Exception("最近1天内API调用费用超过设定上限!") 120 | 121 | return True -------------------------------------------------------------------------------- /llm_api/mongodb_init.py: -------------------------------------------------------------------------------- 1 | import os 2 | from config import ENABLE_MONOGODB 3 | from pymongo import MongoClient 4 | 5 | # 从环境变量获取 MongoDB URI,如果没有则使用默认值 6 | mongo_uri = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/') 7 | mongo_client = MongoClient(mongo_uri) if ENABLE_MONOGODB else None 8 | -------------------------------------------------------------------------------- /llm_api/openai_api.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from openai import OpenAI 3 | from .chat_messages import ChatMessages 4 | 5 | # Pricing reference: https://openai.com/api/pricing/ 6 | gpt_model_config = { 7 | "gpt-4o": { 8 | "Pricing": (2.50/1000, 10.00/1000), 9 | "currency_symbol": '$', 10 | }, 11 | "gpt-4o-mini": { 12 | "Pricing": (0.15/1000, 0.60/1000), 13 | "currency_symbol": '$', 14 | }, 15 | "o1-preview": { 16 | "Pricing": (15/1000, 60/1000), 17 | "currency_symbol": '$', 18 | }, 19 | "o1-mini": { 20 | "Pricing": (3/1000, 12/1000), 21 | "currency_symbol": '$', 22 | }, 23 | } 24 | # https://platform.openai.com/docs/guides/reasoning 25 | 26 | def stream_chat_with_gpt(messages, model='gpt-3.5-turbo-1106', response_json=False, api_key=None, base_url=None, max_tokens=4_096, n=1, proxies=None): 27 | if api_key is None: 28 | raise Exception('未提供有效的 api_key!') 29 | 30 | client_params = { 31 | "api_key": api_key, 32 | } 33 | 34 | if base_url: 35 | client_params['base_url'] = base_url 36 | 37 | if proxies: 38 | httpx_client = httpx.Client(proxy=proxies) 39 | client_params["http_client"] = httpx_client 40 | 41 | client = OpenAI(**client_params) 42 | 43 | if model in ['o1-preview', ] and messages[0]['role'] == 'system': 44 | messages[0:1] = [{'role': 'user', 'content': messages[0]['content']}, {'role': 'assistant', 'content': ''}] 45 | 46 | chatstream = client.chat.completions.create( 47 | stream=True, 48 | model=model, 49 | messages=messages, 50 | max_tokens=max_tokens, 51 | response_format={ "type": "json_object" } if response_json else None, 52 | n=n 53 | ) 54 | 55 | messages.append({'role': 'assistant', 'content': ''}) 56 | content = ['' for _ in range(n)] 57 | for part in chatstream: 58 | for choice in part.choices: 59 | content[choice.index] += choice.delta.content or '' 60 | messages[-1]['content'] = content if n > 1 else content[0] 61 | yield messages 62 | 63 | return messages 64 | 65 | 66 | if __name__ == '__main__': 67 | pass 68 | -------------------------------------------------------------------------------- /llm_api/sparkai_api.py: -------------------------------------------------------------------------------- 1 | from sparkai.llm.llm import ChatSparkLLM, ChunkPrintHandler 2 | from sparkai.core.messages import ChatMessage as SparkMessage 3 | 4 | #星火认知大模型Spark Max的URL值,其他版本大模型URL值请前往文档(https://www.xfyun.cn/doc/spark/Web.html)查看 5 | SPARKAI_URL = 'wss://spark-api.xf-yun.com/v4.0/chat' 6 | #星火认知大模型调用秘钥信息,请前往讯飞开放平台控制台(https://console.xfyun.cn/services/bm35)查看 7 | SPARKAI_APP_ID = '01793781' 8 | SPARKAI_API_SECRET = 'YzJkNTI5N2Q5NDY4N2RlNWI5YjA5ZDM4' 9 | SPARKAI_API_KEY = '5dd33ea830aff0c9dff18e2561a5e6c7' 10 | #星火认知大模型Spark Max的domain值,其他版本大模型domain值请前往文档(https://www.xfyun.cn/doc/spark/Web.html)查看 11 | SPARKAI_DOMAIN = '4.0Ultra' 12 | 13 | """ 14 | 5dd33ea830aff0c9dff18e2561a5e6c7&YzJkNTI5N2Q5NDY4N2RlNWI5YjA5ZDM4&01793781 15 | 16 | domain值: 17 | lite指向Lite版本; 18 | generalv3指向Pro版本; 19 | pro-128k指向Pro-128K版本; 20 | generalv3.5指向Max版本; 21 | max-32k指向Max-32K版本; 22 | 4.0Ultra指向4.0 Ultra版本; 23 | 24 | 25 | Spark4.0 Ultra 请求地址,对应的domain参数为4.0Ultra: 26 | wss://spark-api.xf-yun.com/v4.0/chat 27 | Spark Max-32K请求地址,对应的domain参数为max-32k 28 | wss://spark-api.xf-yun.com/chat/max-32k 29 | Spark Max请求地址,对应的domain参数为generalv3.5 30 | wss://spark-api.xf-yun.com/v3.5/chat 31 | Spark Pro-128K请求地址,对应的domain参数为pro-128k: 32 | wss://spark-api.xf-yun.com/chat/pro-128k 33 | Spark Pro请求地址,对应的domain参数为generalv3: 34 | wss://spark-api.xf-yun.com/v3.1/chat 35 | Spark Lite请求地址,对应的domain参数为lite: 36 | wss://spark-api.xf-yun.com/v1.1/chat 37 | """ 38 | 39 | 40 | sparkai_model_config = { 41 | "spark-4.0-ultra": { 42 | "Pricing": (0, 0), 43 | "currency_symbol": '¥', 44 | "url": "wss://spark-api.xf-yun.com/v4.0/chat", 45 | "domain": "4.0Ultra" 46 | } 47 | } 48 | 49 | 50 | 51 | if __name__ == '__main__': 52 | spark = ChatSparkLLM( 53 | spark_api_url=SPARKAI_URL, 54 | spark_app_id=SPARKAI_APP_ID, 55 | spark_api_key=SPARKAI_API_KEY, 56 | spark_api_secret=SPARKAI_API_SECRET, 57 | spark_llm_domain=SPARKAI_DOMAIN, 58 | streaming=True, 59 | ) 60 | messages = [SparkMessage( 61 | role="user", 62 | content='你好呀' 63 | )] 64 | a = spark.stream(messages) 65 | for message in a: 66 | print(message) -------------------------------------------------------------------------------- /llm_api/zhipuai_api.py: -------------------------------------------------------------------------------- 1 | from zhipuai import ZhipuAI 2 | from .chat_messages import ChatMessages 3 | 4 | # Pricing 5 | # https://open.bigmodel.cn/pricing 6 | # GLM-4-Plus 0.05¥/1000 tokens, GLM-4-Air 0.001¥/1000 tokens, GLM-4-FlashX 0.0001¥/1000 tokens, , GLM-4-Flash 0¥/1000 tokens 7 | 8 | # Models 9 | # https://bigmodel.cn/dev/howuse/model 10 | # glm-4-plus、glm-4-air、 glm-4-flashx 、 glm-4-flash 11 | 12 | 13 | 14 | zhipuai_model_config = { 15 | "glm-4-plus": { 16 | "Pricing": (0.05, 0.05), 17 | "currency_symbol": '¥', 18 | }, 19 | "glm-4-air": { 20 | "Pricing": (0.001, 0.001), 21 | "currency_symbol": '¥', 22 | }, 23 | "glm-4-flashx": { 24 | "Pricing": (0.0001, 0.0001), 25 | "currency_symbol": '¥', 26 | }, 27 | "glm-4-flash": { 28 | "Pricing": (0, 0), 29 | "currency_symbol": '¥', 30 | }, 31 | } 32 | 33 | def stream_chat_with_zhipuai(messages, model='glm-4-flash', response_json=False, api_key=None, max_tokens=4_096): 34 | if api_key is None: 35 | raise Exception('未提供有效的 api_key!') 36 | 37 | client = ZhipuAI(api_key=api_key) 38 | 39 | response = client.chat.completions.create( 40 | model=model, 41 | messages=messages, 42 | stream=True, 43 | max_tokens=max_tokens 44 | ) 45 | 46 | messages.append({'role': 'assistant', 'content': ''}) 47 | for chunk in response: 48 | messages[-1]['content'] += chunk.choices[0].delta.content or '' 49 | yield messages 50 | 51 | return messages 52 | 53 | if __name__ == '__main__': 54 | pass -------------------------------------------------------------------------------- /prompts/baseprompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from prompts.chat_utils import chat, log 4 | from prompts.pf_parse_chat import parse_chat 5 | from prompts.prompt_utils import load_text, match_code_block 6 | 7 | def parser(response_msgs): 8 | content = response_msgs.response 9 | blocks = match_code_block(content) 10 | if blocks: 11 | concat_blocks = "\n".join(blocks) 12 | if concat_blocks.strip(): 13 | content = concat_blocks 14 | return content 15 | 16 | 17 | def clean_txt_content(content): 18 | """Remove comments and trim empty lines from txt content""" 19 | lines = [] 20 | for line in content.split('\n'): 21 | if not line.startswith('//'): 22 | lines.append(line) 23 | return '\n'.join(lines).strip() 24 | 25 | 26 | def load_prompt(dirname, name): 27 | txt_path = os.path.join(dirname, f"{name}.txt") 28 | text = load_text(txt_path) 29 | 30 | return text 31 | 32 | def parse_prompt(text, **kwargs): 33 | """ 34 | 从text中解析PromptMessages。 35 | 对于传入的key-values, key可以多也可以少。 36 | 少的key和value为空的那轮对话会被删除。 37 | 多的key不会管。 38 | """ 39 | content = clean_txt_content(text) 40 | 41 | # Find all format keys in content using regex 42 | format_keys = set(re.findall(r'\{(\w+)\}', content)) 43 | 44 | formatted_kwargs = {k: kwargs.get(k, '__delete__') or '__delete__' for k in format_keys} 45 | formatted_kwargs = {k: f"```\n{v.strip()}\n```" for k, v in formatted_kwargs.items()} 46 | prompt = content.format(**formatted_kwargs) if format_keys else content 47 | messages = parse_chat(prompt) 48 | for i in range(len(messages)-2, -1, -1): 49 | if '__delete__' in messages[i]['content']: 50 | assert messages[i]['role'] == 'user' and messages[i+1]['role'] == 'assistant', "__delete__ must be in user's message" 51 | messages.pop(i) 52 | messages.pop(i) 53 | 54 | return messages 55 | 56 | 57 | def parse_input_keys(text): 58 | # Use regex to find the input keys line and parse keys 59 | match = re.search(r'//\s*输入:(.*?)(?:\n|$)', text) 60 | if not match: 61 | return [] 62 | 63 | keys_str = match.group(1).strip() 64 | 65 | keys = [k.strip() for k in keys_str.split(',') if k.strip()] 66 | 67 | return keys 68 | 69 | def main(model, dirname, user_prompt_text, **kwargs): 70 | # Load system prompt 71 | system_prompt = parse_prompt(load_prompt(dirname, "system_prompt"), **kwargs) 72 | 73 | load_from_file_flag = False 74 | if os.path.exists(os.path.join(dirname, user_prompt_text)): 75 | user_prompt_text = load_prompt(dirname, user_prompt_text) 76 | load_from_file_flag = True 77 | else: 78 | if not re.search(r'^user:\n', user_prompt_text, re.MULTILINE): 79 | user_prompt_text = f"user:\n{user_prompt_text}" 80 | 81 | user_prompt = parse_prompt(user_prompt_text, **kwargs) 82 | 83 | context_input_keys = parse_input_keys(user_prompt_text) 84 | if not context_input_keys: 85 | assert not load_from_file_flag, "从本地文件加载Prompt时,本地文件中注释必须指明输入!" 86 | context_kwargs = kwargs 87 | else: 88 | context_kwargs = {k: kwargs[k] for k in context_input_keys} 89 | assert all(context_kwargs.values()), "Missing required context keys" 90 | 91 | context_prompt = parse_prompt(load_prompt(dirname, "context_prompt"), **context_kwargs) 92 | 93 | # Combine all prompts 94 | final_prompt = system_prompt + context_prompt + user_prompt 95 | 96 | # Chat and parse results 97 | for response_msgs in chat(final_prompt, None, model, parse_chat=False): 98 | text = parser(response_msgs) 99 | ret = {'text': text, 'response_msgs': response_msgs} 100 | yield ret 101 | 102 | return ret 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /prompts/chat_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .pf_parse_chat import parse_chat as pf_parse_chat 3 | 4 | from llm_api import ModelConfig, stream_chat 5 | from datetime import datetime # Update this import 6 | import random 7 | 8 | 9 | def chat(messages, prompt, model:ModelConfig, parse_chat=False, response_json=False): 10 | if prompt: 11 | if parse_chat: 12 | messages = pf_parse_chat(prompt) 13 | else: 14 | messages = messages + [{'role': 'user', 'content': prompt}] 15 | 16 | result = yield from stream_chat(model, messages, response_json=response_json) 17 | 18 | return result 19 | 20 | 21 | def log(prompt_name, prompt, parsed_result): 22 | output_dir = os.path.join(os.path.dirname(__file__), 'output') 23 | os.makedirs(output_dir, exist_ok=True) 24 | 25 | random_suffix = random.randint(1000, 9999) 26 | filename = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + f"_{prompt_name}_{random_suffix}.txt" 27 | filepath = os.path.join(output_dir, filename) 28 | 29 | response_msgs = parsed_result['response_msgs'] 30 | response = response_msgs.response 31 | 32 | with open(filepath, 'w', encoding='utf-8') as f: 33 | f.write("----------prompt--------------\n") 34 | f.write(prompt + "\n\n") 35 | f.write("----------response-------------\n") 36 | f.write(response + "\n\n") 37 | f.write("-----------parse----------------\n") 38 | for k, v in parsed_result.items(): 39 | if k != 'response_msgs': 40 | f.write(f"{k}:\n{v}\n\n") 41 | -------------------------------------------------------------------------------- /prompts/common_parser.py: -------------------------------------------------------------------------------- 1 | def parse_content(response_msgs): 2 | return response_msgs[-1]['content'] 3 | 4 | 5 | def parse_last_code_block(response_msgs): 6 | from prompts.prompt_utils import match_code_block 7 | content = response_msgs.response 8 | blocks = match_code_block(content) 9 | if blocks: 10 | content = blocks[-1] 11 | return content 12 | 13 | def parse_named_chunk(response_msgs, name): 14 | from prompts.prompt_utils import parse_chunks_by_separators 15 | content = response_msgs[-1]['content'] 16 | 17 | chunks = parse_chunks_by_separators(content, [r'\S*', ]) 18 | if name in chunks: 19 | return chunks[name] 20 | else: 21 | return content 22 | -------------------------------------------------------------------------------- /prompts/idea-examples.yaml: -------------------------------------------------------------------------------- 1 | examples: 2 | - idea: |- 3 | 随身携带王者荣耀召唤器,每天随机英雄三分钟附体! 4 | - idea: |- 5 | 商业大亨穿越到哈利波特世界,但我不会魔法 6 | - idea: |- 7 | 末日重生归来,我抢先把所有反派关在了地牢里 8 | - idea: |- 9 | 身为高中生兼当红轻小说作家的我,正被年纪比我小且从事声优工作的女同学掐住脖子。 10 | -------------------------------------------------------------------------------- /prompts/pf_parse_chat.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import re 4 | import sys 5 | import time 6 | from typing import List, Mapping 7 | 8 | from jinja2 import Template 9 | 10 | 11 | def validate_role(role: str, valid_roles: List[str] = None): 12 | if not valid_roles: 13 | valid_roles = ["assistant", "function", "user", "system"] 14 | 15 | if role not in valid_roles: 16 | valid_roles_str = ','.join([f'\'{role}:\\n\'' for role in valid_roles]) 17 | raise ValueError(f"Invalid role: {role}. Valid roles are: {valid_roles_str}") 18 | 19 | 20 | def try_parse_name_and_content(role_prompt): 21 | # customer can add ## in front of name/content for markdown highlight. 22 | # and we still support name/content without ## prefix for backward compatibility. 23 | pattern = r"\n*#{0,2}\s*name:\n+\s*(\S+)\s*\n*#{0,2}\s*content:\n?(.*)" 24 | match = re.search(pattern, role_prompt, re.DOTALL) 25 | if match: 26 | return match.group(1), match.group(2) 27 | return None 28 | 29 | 30 | def parse_chat(chat_str, images: List = None, valid_roles: List[str] = None): 31 | if not valid_roles: 32 | valid_roles = ["system", "user", "assistant", "function"] 33 | 34 | # openai chat api only supports below roles. 35 | # customer can add single # in front of role name for markdown highlight. 36 | # and we still support role name without # prefix for backward compatibility. 37 | separator = r"(?i)^\s*#?\s*(" + "|".join(valid_roles) + r")\s*:\s*\n" 38 | 39 | images = images or [] 40 | hash2images = {str(x): x for x in images} 41 | 42 | chunks = re.split(separator, chat_str, flags=re.MULTILINE) 43 | chat_list = [] 44 | 45 | for chunk in chunks: 46 | last_message = chat_list[-1] if len(chat_list) > 0 else None 47 | if last_message and "role" in last_message and "content" not in last_message: 48 | parsed_result = try_parse_name_and_content(chunk) 49 | if parsed_result is None: 50 | # "name" is required if the role is "function" 51 | if last_message["role"] == "function": 52 | raise ValueError("Function role must have content.") 53 | # "name" is optional for other role types. 54 | else: 55 | last_message["content"] = to_content_str_or_list(chunk, hash2images) 56 | else: 57 | last_message["name"] = parsed_result[0] 58 | last_message["content"] = to_content_str_or_list(parsed_result[1], hash2images) 59 | else: 60 | if chunk.strip() == "": 61 | continue 62 | # Check if prompt follows chat api message format and has valid role. 63 | # References: https://platform.openai.com/docs/api-reference/chat/create. 64 | role = chunk.strip().lower() 65 | validate_role(role, valid_roles=valid_roles) 66 | new_message = {"role": role} 67 | chat_list.append(new_message) 68 | return chat_list 69 | 70 | 71 | def to_content_str_or_list(chat_str: str, hash2images: Mapping): 72 | chat_str = chat_str.strip() 73 | chunks = chat_str.split("\n") 74 | include_image = False 75 | result = [] 76 | for chunk in chunks: 77 | if chunk.strip() in hash2images: 78 | image_message = {} 79 | image_message["type"] = "image_url" 80 | image_url = hash2images[chunk.strip()].source_url \ 81 | if hasattr(hash2images[chunk.strip()], "source_url") else None 82 | if not image_url: 83 | image_bs64 = hash2images[chunk.strip()].to_base64() 84 | image_mine_type = hash2images[chunk.strip()]._mime_type 85 | image_url = {"url": f"data:{image_mine_type};base64,{image_bs64}"} 86 | image_message["image_url"] = image_url 87 | result.append(image_message) 88 | include_image = True 89 | elif chunk.strip() == "": 90 | continue 91 | else: 92 | result.append({"type": "text", "text": chunk}) 93 | return result if include_image else chat_str 94 | 95 | -------------------------------------------------------------------------------- /prompts/prompt_utils.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | import json 3 | import yaml 4 | import chardet 5 | from jinja2 import Environment, FileSystemLoader 6 | 7 | import re 8 | import sys, os 9 | root_path = os.path.abspath(os.path.join(os.path.abspath(__file__), "../..")) 10 | if root_path not in sys.path: 11 | sys.path.append(root_path) 12 | 13 | from llm_api.chat_messages import ChatMessages 14 | 15 | def can_parse_json(response): 16 | try: 17 | json.loads(response) 18 | return True 19 | except: 20 | return False 21 | 22 | def match_first_json_block(response): 23 | if can_parse_json(response): 24 | return response 25 | 26 | pattern = r"(?<=[\r\n])```json(.*?)```(?=[\r\n])" 27 | matches = re.findall(pattern, '\n' + response + '\n', re.DOTALL) 28 | if not matches: 29 | pattern = r"(?<=[\r\n])```(.*?)```(?=[\r\n])" 30 | matches = re.findall(pattern, '\n' + response + '\n', re.DOTALL) 31 | 32 | if matches: 33 | json_block = matches[0] 34 | if can_parse_json(json_block): 35 | return json_block 36 | else: 37 | json_block = json_block.replace('\r\n', '') # 在continue generate情况下,不同部分之间可能有多出的换行符,导致合起来之后json解析失败 38 | if can_parse_json(json_block): 39 | return json_block 40 | else: 41 | raise Exception(f"无法解析JSON代码块") 42 | else: 43 | raise Exception(f"没有匹配到JSON代码块") 44 | 45 | def parse_first_json_block(response_msgs: ChatMessages): 46 | assert response_msgs[-1]['role'] == 'assistant' 47 | return json.loads(match_first_json_block(response_msgs[-1]['content'])) 48 | 49 | def match_code_block(response): 50 | response = re.sub(r'\r\n', r'\n', response) 51 | response = re.sub(r'\r', r'\n', response) 52 | pattern = r"```(?:\S*\s)(.*?)```" 53 | matches = re.findall(pattern, response + '```', re.DOTALL) 54 | return matches 55 | 56 | def json_dumps(json_object): 57 | return json.dumps(json_object, ensure_ascii=False, indent=1) 58 | 59 | def parse_chunks_by_separators(string, separators): 60 | separator_pattern = r"^\s*###\s*(" + "|".join(separators) + r")\s*\n" 61 | 62 | chunks = re.split(separator_pattern, string, flags=re.MULTILINE) 63 | 64 | ret = {} 65 | 66 | current_title = None 67 | 68 | for i, chunk in enumerate(chunks): 69 | if i % 2 == 1: 70 | current_title = chunk.strip() 71 | ret[current_title] = "" 72 | elif current_title: 73 | ret[current_title] += chunk.strip() 74 | 75 | return ret 76 | 77 | def construct_chunks_and_separators(chunk2separator): 78 | return "\n\n".join([f"### {k}\n{v}" for k, v in chunk2separator.items()]) 79 | 80 | def match_chunk_span_in_text(chunk, text): 81 | diff = difflib.Differ().compare(chunk, text) 82 | 83 | chunk_i = 0 84 | text_i = 0 85 | 86 | for tag in diff: 87 | if tag.startswith(' '): 88 | chunk_i += 1 89 | text_i += 1 90 | elif tag.startswith('+'): 91 | text_i += 1 92 | else: 93 | chunk_i += 1 94 | 95 | if chunk_i == 1: 96 | l = text_i - 1 97 | 98 | if chunk_i == len(chunk): 99 | r = text_i 100 | return l, r 101 | 102 | def load_yaml(file_path): 103 | with open(file_path, 'r', encoding='utf-8') as file: 104 | return yaml.safe_load(file) 105 | 106 | def load_text(file_path, read_size=None): 107 | # Read the raw bytes first 108 | with open(file_path, 'rb') as file: 109 | raw_data = file.read(read_size) 110 | 111 | # Detect the encoding 112 | result = chardet.detect(raw_data[:10000]) 113 | encoding = result['encoding'] or 'utf-8' # Fallback to utf-8 if detection fails 114 | 115 | # Decode the content with detected encoding 116 | try: 117 | return raw_data.decode(encoding, errors='ignore') 118 | except UnicodeDecodeError: 119 | # Fallback to utf-8 if the detected encoding fails 120 | return raw_data.decode('utf-8', errors='ignore') 121 | 122 | def load_jinja2_template(file_path): 123 | env = Environment(loader=FileSystemLoader(os.path.dirname(file_path))) 124 | template = env.get_template(os.path.basename(file_path)) 125 | 126 | return template 127 | 128 | 129 | -------------------------------------------------------------------------------- /prompts/test_format_plot.yaml: -------------------------------------------------------------------------------- 1 | - |- 2 | 李珣呆立不动,背后传来水声,那雾中的女子在悠闲洗浴。 3 | 4 | 李珣对这种场景感到震惊,认为她绝非普通人,决定乖乖表现。 5 | 6 | 尽管转身,他仍紧闭眼睛,慌乱道歉。 7 | 8 | 那女子静默片刻,继续泼水声令李珣难以忍受。 9 | 10 | 她随后淡然问话,李珣感到对方危险可怕。 11 | 12 | 她询问李珣怎么上山,李珣答“爬上来的”,这让对方略显惊讶。 13 | 14 | 女子探问他身份,李珣庆幸自己内息如同名门,为保命决定实话实说,自报身份并讲述过往经历,隐去危险细节。 15 | 16 | 这番表白得到女子的肯定,虽然语调淡然,但意思清晰。她让李珣暂时离开,待她穿戴整齐。 17 | 18 | 李珣照做,在岸边等候。女子走出雾气,身姿曼妙,令他看呆。 19 | 20 | 铃声伴着她的步伐,让李珣心神为之所牵。 21 | 22 | 当水气散尽,绝美之貌让李珣惊叹不已,几乎想要顶礼膜拜。 23 | - |- 24 | 隐隐间,似乎有一丝若有若无的铃声,缓缓地沁入水雾之中,与这迷茫天水交织在一处,细碎的抖颤之声,天衣无缝地和这缓步而来的身影合在一处,攫牢了李珣的心神。 25 | 而当眼前水气散尽,李珣更是连呼吸都停止了。此为何等佳人? 26 | 李珣只觉得眼前洁净不沾一尘的娇颜,便如一朵临水自照的水仙,清丽中别有孤傲,闲适中却见轻愁。 27 | 他还没找到形容眼前佳人的辞句,便已觉得两腿发软,恨不能跪倒地上,顶礼膜拜。 28 | 29 | -------------------------------------------------------------------------------- /prompts/test_prompt.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys, os 3 | root_path = os.path.abspath(os.path.join(os.path.abspath(__file__), "../..")) 4 | sys.path.append(root_path) 5 | 6 | from prompts.load_utils import run_prompt 7 | 8 | def json_load(input_file): 9 | with open(input_file, 'r', encoding='utf-8') as f: 10 | if input_file.endswith('.jsonl'): 11 | return [json.loads(line) for line in f.readlines()] 12 | else: 13 | return json.load(f) 14 | 15 | 16 | if __name__ == "__main__": 17 | path = "./prompts/创作正文" 18 | kwargs = json_load(os.path.join(path, 'data.jsonl'))[0] 19 | 20 | gen = run_prompt(source=path, **kwargs) 21 | 22 | list(gen) -------------------------------------------------------------------------------- /prompts/text-plot-examples.yaml: -------------------------------------------------------------------------------- 1 | prompt: |- 2 | 逐句简写下面的小说正文。如果原句本来就很少,考虑将原文多个(2-5个)句子简写为一个。 3 | examples: 4 | - title: 青吟 5 | text: |- 6 | 李珣呆立当场,手足无措。 7 | 后方水声不止,那位雾后佳人并未停下动作,还在那里撩水净身。 8 | 李珣听得有些傻了,虽然他对异性的认识不算全面,可是像后面这位,能够在男性身旁悠闲沐浴的,是不是也稀少了一些? 9 | 李珣毕竟不傻,他此时也已然明白,现在面对的是一位绝对惹不起的人物,在这种强势人物眼前,做一个乖孩子,是最聪明不过的了! 10 | 他虽已背过身来,却还是紧闭眼睛,生怕无意间又冒犯了人家,这无关道德风化,仅仅是为了保住小命而已。 11 | 确认了一切都已稳妥,他这才结结巴巴地开口:“对……对不住,我不是……故意的!” 12 | 对方并没有即时回答,李珣只听到哗哗的泼水声,每一点声息,都是对他意志的摧残。 13 | 也不知过了多久,雾后的女子开口了:“话是真的,却何必故作紧张?事不因人而异,一个聪明人和一个蠢材,要承担的后果都是一样的。” 14 | 李珣顿时哑口无言。 15 | 后面这女人,实在太可怕了。 16 | 略停了一下,这女子又道:“看你修为不济,也御不得剑,是怎么上来这里的?” 17 | 李珣脱口道:“爬上来的!” 18 | “哦?”女子的语气中第一次有了情绪存在,虽只是一丝淡淡的惊讶,却也让李珣颇感自豪。只听她问道:“你是明心剑宗的弟子?” 19 | 这算是盘问身分了。李珣首先庆幸他此时内息流转的形式,是正宗的明心剑宗嫡传。否则,幽明气一出,恐怕对面之人早一掌劈了他! 20 | 庆幸中,他的脑子转了几转,将各方面的后果都想了一遍,终是决定“据实”以告。 21 | “惭愧,只是个不入流的低辈弟子……” 22 | 李珣用这句话做缓冲,随即便从自己身世说起,一路说到登峰七年的经历。 23 | 当然,其中关于血散人的死亡威胁,以及近日方得到的《幽冥录》等,都略去不提。只说是自己一心向道,被淘汰之后,便去爬坐忘峰以证其心云云。 24 | 这段话本是他在心中温养甚久,准备做为日后说辞使用,虽然从未对人道过,但腹中已是熟练至极。 25 | 初时开口,虽然还有些辞语上的生涩,但到后来,已是流利无比,许多词汇无需再想,便脱口而出,却是再“真诚”不过。 26 | 他一开口,说了足足有一刻钟的工夫,这当中,那女子也问了几句细节,却也都在李珣计画之内,回应得也颇为顺畅。 27 | 如此,待他告一段落之时,那女人竟让他意外地道了一声:“如今竟也有这般人物!” 28 | 语气虽然还是平平淡淡的,像是在陈述毫不出奇的一件平凡事,但其中意思却是到了。李珣心中暗喜,口中当然还要称谢。 29 | 女子也不在乎他如何反应,只是又道一声:“你孤身登峰七年,行程二十余万里,能承受这种苦楚,也算是人中之杰。我这样对你,倒是有些不敬,你且左行百步上岸,待我穿戴整齐,再与你相见。” 30 | 李珣自是依言而行,上了岸去,也不敢多话,只是恭立当场,面上作了十足工夫。 31 | 也只是比他晚个数息时间,一道人影自雾气中缓缓走来,水烟流动,轻云伴生,虽仍看不清面目,但她凌波微步,长裙摇曳的体态,却已让李珣看呆了眼,只觉得此生再没见过如此人物。 32 | 隐隐间,似乎有一丝若有若无的铃声,缓缓地沁入水雾之中,与这迷茫天水交织在一处,细碎的抖颤之声,天衣无缝地和这缓步而来的身影合在一处,攫牢了李珣的心神。 33 | 而当眼前水气散尽,李珣更是连呼吸都停止了。此为何等佳人? 34 | 李珣只觉得眼前洁净不沾一尘的娇颜,便如一朵临水自照的水仙,清丽中别有孤傲,闲适中却见轻愁。 35 | 他还没找到形容眼前佳人的辞句,便已觉得两腿发软,恨不能跪倒地上,顶礼膜拜。 36 | plot: |- 37 | 李珣呆立不动,背后传来水声,那雾中的女子在悠闲洗浴。 38 | 39 | 李珣对这种场景感到震惊,认为她绝非普通人,决定乖乖表现。 40 | 41 | 尽管转身,他仍紧闭眼睛,慌乱道歉。 42 | 43 | 那女子静默片刻,继续泼水声令李珣难以忍受。 44 | 45 | 她随后淡然问话,李珣感到对方危险可怕。 46 | 47 | 她询问李珣怎么上山,李珣答“爬上来的”,这让对方略显惊讶。 48 | 49 | 女子探问他身份,李珣庆幸自己内息如同名门,为保命决定实话实说,自报身份并讲述过往经历,隐去危险细节。 50 | 51 | 这番表白得到女子的肯定,虽然语调淡然,但意思清晰。她让李珣暂时离开,待她穿戴整齐。 52 | 53 | 李珣照做,在岸边等候。女子走出雾气,身姿曼妙,令他看呆。 54 | 55 | 铃声伴着她的步伐,让李珣心神为之所牵。 56 | 57 | 当水气散尽,绝美之貌让李珣惊叹不已,几乎想要顶礼膜拜。 58 | - title: 纳兰嫣然 59 | text: |- 60 | 云岚宗后山山巅,云雾缭绕,宛如仙境。 61 | 62 | 在悬崖边缘处的一块凸出的黑色岩石之上,身着月白色裙袍的女子,正双手结出修炼的印结,闭目修习,而随着其一呼一吸间,形成完美的循环,在每次循环的交替间,周围能量浓郁的空气中都将会渗发出一股股淡淡的青色气流,气流盘旋在女子周身,然后被其源源不断的吸收进身体之内,进行着炼化,收纳…… 63 | 64 | 当最后一缕青色气流被女子吸进身体之后,她缓缓的睁开双眸,淡淡的青芒从眸子中掠过,披肩的青丝,霎那间无风自动,微微飞扬。 65 | 66 | “纳兰师姐,纳兰肃老爷子来云岚宗了,他说让你去见他。” 67 | 68 | 见到女子退出了修炼状态,一名早已经等待在此处的侍女,急忙恭声道。 69 | 70 | “父亲?他来做什么?” 71 | 72 | 闻言,女子黛眉微皱,疑惑的摇了摇头,优雅的站起身子,立于悬崖之边,迎面而来的轻风。将那月白裙袍吹得紧紧的贴在女子玲珑娇躯之上,显得凹凸有致,极为诱人。 73 | 74 | 目光慵懒的在深不见底的山崖下扫了扫,女子玉手轻拂了拂月白色的裙袍,旋即转身离开了这处她专用的修炼之所。 75 | 76 | 宽敞明亮地大厅之中。一名脸色略微有些阴沉地中年人,正端着茶杯。放在桌上的手掌,有些烦躁地不断敲打着桌面。 77 | 78 | 纳兰肃现在很烦躁,因为他几乎是被他的父亲纳兰桀用棍子撵上的云岚宗。 79 | 80 | 他没想到,他仅仅是率兵去帝国西部驻扎了一年而已。自己这个胆大包天的女儿,竟然就敢私自把当年老爷子亲自定下的婚事给推了。 81 | 82 | 家族之中,谁不知道纳兰桀极其要面子。而纳兰嫣然现在的这举动,无疑会让别人说成是他纳兰家看见萧家势力减弱,不屑与之联婚,便毁信弃诺。 83 | 84 | 这种闲言碎语,让得纳兰桀每天都在家中暴跳如雷。若不是因为动不了身的缘故。恐怕他早已经拖着那行将就木的身体,来爬云岚山了。 85 | 86 | 对于纳兰家族与萧家的婚事。说实在的,其实纳兰肃也并不太赞成。毕竟当初的萧炎,几乎是废物的代名词。让他将自己这容貌与修炼天赋皆是上上之选的女儿嫁给一个废物。纳兰肃心中还真是一百个不情愿。 87 | 88 | 不过,当初是当初,根据他所得到的消息,现在萧家的那小子,不仅脱去了废物的名头,而且所展现出来的修炼速度,几乎比他小时候最巅峰的时候还要恐怖。 89 | 90 | 此时萧炎所表现而出的潜力,无疑已经能够让得纳兰肃重视。然而,纳兰嫣然的私自举动,却是把双方的关系搞成了冰冷的僵局,这让得纳兰肃极为的尴尬。 91 | 92 | 按照这种关系下去,搞不好,他纳兰肃不仅会失去一个潜力无限的女婿,而且说不定还会因此让得他对纳兰家族怀恨在心。 93 | 94 | 只要想着一个未来有机会成为斗皇的强者或许会敌视着纳兰家族,纳兰肃在后怕之余,便是气得直跳脚。 95 | 96 | “这丫头。现在胆子是越来越大了……” 97 | 98 | 越想越怒,纳兰肃手中的茶杯忽然重重的跺在桌面之上,茶水溅了满桌。将一旁侍候的侍女吓了一跳,赶忙小心翼翼的再次换了一杯。云岚宗,怎么不通知一下焉儿啊?” 99 | 100 | 就在纳兰肃心头发怒之时,女子清脆的声音,忽然地在大厅内响起,月白色的倩影,从纱帘中缓缓行出,对着纳兰肃甜甜笑道。 101 | 102 | “哼,你眼里还有我这个父亲?我以为你成为了云韵的弟子,就不知道什么是纳兰家族了呢!”望着这出落得越来越水灵的女儿,纳兰肃心头的怒火稍稍收敛了一点,冷哼道。 103 | 104 | 瞧着纳兰肃不甚好看的脸色,纳兰嫣然无奈地摇了摇头,对着那一旁的侍女挥了挥手,将之遣出。 105 | 106 | “父亲,一年多不见,你一来就训斥焉儿,等下次回去,我可一定要告诉母亲!”待得侍女退出之后,纳兰嫣然顿时皱起了俏鼻,在纳兰肃身旁坐下,撒娇般的哼道。 107 | 108 | “回去?你还敢回去?”闻言,纳兰肃嘴角一裂:“你敢回去,看你爷爷敢不敢打断你的腿……” 109 | 110 | 撇了撇嘴,心知肚明的纳兰嫣然,自然清楚纳兰肃话中的意思。 111 | 112 | “你应该知道我来此处的目的吧?” 113 | 114 | 狠狠的灌了一口茶水,纳兰肃阴沉着脸道。 115 | 116 | “是为了我悔婚的事吧?” 117 | 118 | 纤手把玩着一缕青丝,纳兰嫣然淡淡地道。 119 | 120 | 看着纳兰嫣然这平静的模样,纳兰肃顿时被气乐了,手掌重重地拍在桌上,怒声道:“婚事是你爷爷当年亲自允下的,是谁让你去解除的?” 121 | 122 | “那是我的婚事,我才不要按照你们的意思嫁给谁,我的事,我自己会做主!我不管是谁允下的,我只知道,如果按照约定。嫁过去的是我,不是爷爷!”提起这事,纳兰嫣然也是脸现不愉,性子有些独立的她,很讨厌自己的大事按照别人所指定的路线行走。即使这人是她的长辈。 123 | 124 | “你别以为我不知道,你无非是认为萧炎当初一个废物配不上你是吧?可现在人家潜力不会比你低!以你在云岚宗的地位,应该早就接到过有关他实力提升的消息吧?”纳兰肃怒道。 125 | 126 | 纳兰嫣然黛眉微皱,脑海中浮现当年那充满着倔性的少年,红唇微抿,淡淡地道:“的确听说过一些关于他的消息,没想到,他竟然还真的能脱去废物的名头,这倒的确让我很意外。” 127 | 128 | “意外?一句意外就行了?你爷爷开口了。让你找个时间,再去一趟乌坦城,最好能道个歉把僵硬的关系弄缓和一些。”纳兰肃皱眉道。 129 | 130 | “道歉?不可能!” 131 | 132 | 闻言,纳兰嫣然柳眉一竖,毫不犹豫地直接拒绝,冷哼道:“他萧炎虽然不再是废物,可我纳兰嫣然依然不会嫁给他!更别提让我去道什么歉,你们喜欢,那就自己去,反正我不会再去乌坦城!” 133 | 134 | “这哪有你回绝的余地!祸是你闯的,你必须去给我了结了!”瞧得纳兰嫣然竟然一口回绝,纳兰肃顿时勃然大怒。 135 | 136 | “不去!” 137 | 138 | 冷着俏脸,纳兰嫣然扬起雪白的下巴,脸颊上有着一抹与生俱来的娇贵:“他萧炎不是很有本事么?既然当年敢应下三年的约定,那我纳兰嫣然就在云岚宗等着他来挑战,若是我败给他,为奴为婢,随他处置便是,哼,如若不然,想要我道歉。不可能!” 139 | 140 | “混账,如果三年约定,你最后输了,到时候为奴为婢,那岂不是连带着我纳兰家族,也把脸给丢光了?”纳兰肃怒斥道。 141 | 142 | “谁说我会输给他?就算他萧炎回复了天赋,可我纳兰嫣然难道会差了他什么不成?而且云岚宗内高深功法不仅数不胜数,高级斗技更是收藏丰厚,更有丹王古河爷爷帮我炼制丹药。这些东西。他一个小家族的少爷难道也能有?说句不客气的,恐怕光光是寻找高级斗气功法。就能让得他花费好十几年时间!”被纳兰肃这般小瞧,纳兰嫣然顿时犹如被踩到尾巴的母猫一般,她最讨厌的,便是被人说成比不上那曾经被自己万般看不起的废物! 143 | 144 | 被女儿当着面这般吵闹,纳兰肃气得吹胡子瞪眼,猛然站起身来,扬起手掌就欲对着纳兰嫣然扇下去。 145 | 146 | “纳兰兄,你可不要乱来啊。”瞧着纳兰肃的动作,一道白影急忙掠了进来,挡在了纳兰嫣然面前。 147 | 148 | “葛叶,你这个混蛋,听说上次去萧家,还是你陪地嫣然?”望着挡在面前的人影,纳兰肃更是怒气暴涨,大怒道。 149 | 150 | 尴尬一笑,葛叶苦笑道:“这是宗主的意思,我也没办法。” 151 | plot: |- 152 | 尴尬一笑,葛叶苦笑道:“这是宗主的意思,我也没办法。” 153 | 154 | 云岚宗后山,云雾缭绕。月白裙袍的女子在山巅悬崖边修炼,吸收青色气流。 155 | 156 | 当她吸收完最后一缕气流后,睁开双眸,青芒掠过,青丝微动。一名侍女走上前恭敬道:“纳兰师姐,纳兰肃老爷子来了,让你过去见他。” 157 | 158 | 女子黛眉微皱,疑惑地站起身,转身离开修炼之所。大厅内,中年人纳兰肃端着茶杯,脸色阴沉,不断敲打桌面。 159 | 160 | 纳兰肃被父亲纳兰桀用棍子赶上山来,因为女儿纳兰嫣然私自退了婚约,纳兰家族因此陷入困境。萧炎本是废物,但现在展现出强大潜力,纳兰肃对此很重视,但女儿的举动让他尴尬。 161 | 162 | 纳兰嫣然出现,父女二人开战言语。纳兰肃火冒三丈,纳兰嫣然反对重新接触萧炎,认为只有她自己可以决定自己的婚事。 163 | 164 | 纳兰肃怒斥,纳兰嫣然强硬回击,表示她不会道歉,只会等待萧炎挑战她。如果她输了,愿意为奴为婢,但她相信自己不会输。 165 | 166 | 纳兰肃气愤欲扇女儿耳光,一道白影葛叶及时挡住,纳兰肃更怒,葛叶苦笑解释这是宗主的意思。 167 | - title: 极阴老祖 168 | text: |- 169 | “而且你真以为,你能做得了主吗?老怪物,你也不用躲躲藏藏了,快点现身吧!”中年人阴厉的说道。 170 | 171 | 听了这话,韩立等修士吓了一大跳,急忙往四处张望了起来。难道极阴老祖就在这里? 172 | 173 | 可是四周仍然平静如常,并没有什么异常出现。这下众修士有些摸不着头脑了,再次往中年人和乌丑望去。 174 | 175 | “你搞什么鬼?我怎么做不了……”乌丑一开始也有些愕然,但话只说了一半时神色一滞,并开始露出了一丝古怪的神色。 176 | 177 | 他用这种神色直直的盯着中年人片刻后,诡异的笑了起来。“不错,不错!不愧为我当年最看重的弟子之一,竟然一眼就看出老夫的身份来了。” 178 | 179 | 说话之间,乌丑的面容开始模糊扭曲了起来,不一会儿后,就在众人惊诧的目光中,化为了一个同样瘦小,却两眼微眯的丑陋老者。 180 | 181 | 这下,韩立等人后背直冒寒气。 182 | 183 | “附身大法!我就知道,你怎会将如此重要的事情交予一个晚辈去做,还是亲自来了。尽管这不是你的本体。”中年人神色紧张的瞅向老者,声音却低缓的说道。 184 | 185 | “乖徒弟,你还真敢和为师动手不成?”新出现的老者嘴唇未动一下,却从腹部发出尖锐之极的声音,刺得众人的耳膜隐隐作痛,所有人都情不自禁的后退了几步。 186 | 187 | “哼!徒弟?当年你对我们打杀任凭一念之间,稍有不从者,甚至还要抽神炼魂,何曾把我们当过徒弟看待!只不过是你的奴隶罢了!而且,你现在只不过施展的是附身之术而已,顶多能发挥三分之一的修为,我有什么可惧的!”中年人森然的说道,随后两手一挥,身前的鬼头凭空巨涨了起来,瞬间变得更加狰狞可怖起来。 188 | 189 | 紫灵仙子和韩立等修士,则被这诡异的局面给震住了,一时间神色各异! 190 | 191 | 老者听了中年人的话,并没有动怒,反而淡淡的说道:“不错,若是百余年前,你说这话的确没错!凭我三分之一的修为,想要活捉你还真有些困难。但是现在……” 192 | 193 | 说到这里时,他露出了一丝尖刻的讥笑之意。 194 | 195 | 第四卷 风起海外 第四百零六章 天都尸火 196 | 197 | 中年人听了老者的话,眼中神光一缩,露出难以置信的神情。 198 | 199 | “难道你练成了那魔功?”他的声音有些惊惧。 200 | 201 | “你猜出来更好,如果现在乖乖束手就擒的话,我还能放你一条活路。否则后果怎样,不用我说你应该也知道才对。”老者一边说着,一边伸出一只手掌,只听“嗤啦”一声,一团漆黑如墨的火球漂浮在了手心之上。 202 | 203 | “天都尸火!你终于练成了。”中年人的脸色灰白无比,声音发干的说道,竟惊骇的有点嘶哑了。 204 | 205 | 见此情景,极阴祖师冷笑了一声,忽然转过头来,对紫灵仙子等人傲然的说道:“你们听好了,本祖师今天心情很好,可以放你们一条活路!只要肯从此归顺极阴岛,你们还可以继续的逍遥自在。但是本祖师下达的命令必须老老实实的完成,否则就是魂飞魄散的下场。现在在这些禁神牌上交出你们三分之一的元神,就可以安然离去了。”说完这话,他另一只手往怀内一摸,掏出了数块漆黑的木牌,冷冷的望着众人。 206 | 207 | 韩立和其他的修士听了,面面相觑起来。既没有人蠢到主动上前去接此牌,也没人敢壮起胆子说不接,摄于对方的名头,一时场中鸦雀无声。 208 | plot: |- 209 | “你以为你能做主吗?老怪物,现身吧!”中年人阴冷道。 210 | 211 | 韩立等人吓了一跳,四处张望,但周围平静,他们再次看向中年人和乌丑。 212 | 213 | 乌丑开始糊涂,但转而露出怪异表情,说:“不错,你看出了我的身份。”随后,乌丑的面容扭曲,变成一个瘦小丑陋的老者,韩立等人惊恐不已。 214 | 215 | “附身大法!我就知道你会亲自来。”中年人低声说。 216 | 217 | 老者发出尖锐声音道:“你敢和我动手?” 218 | 219 | 中年人冷笑:“当年你视我们为奴隶。你现在只施展了附身之术,有什么可惧!”随即,鬼头变得更加狰狞。 220 | 221 | 老者淡然道:“若百年前你说的对,但现在……”露出讥笑。 222 | 223 | 中年人惊恐道:“难道你练成了那魔功?” 224 | 225 | 老者冷笑,召出一团漆黑的火球:“天都尸火!现在束手就擒,否则后果自负。”转头对紫灵仙子等人道:“归顺极阴岛,交出三分之一元神,否则魂飞魄散。”掏出数块黑色木牌。 226 | 227 | 韩立等人面面相觑,没人敢动,也不敢拒绝,场中一片沉寂。 -------------------------------------------------------------------------------- /prompts/tool_parser.py: -------------------------------------------------------------------------------- 1 | from promptflow.core import tool 2 | from enum import Enum 3 | 4 | 5 | class ResponseType(str, Enum): 6 | CONTENT = "content" 7 | SEPARATORS = "separators" 8 | CODEBLOCK = "codeblock" 9 | 10 | 11 | import sys, os 12 | root_path = os.path.abspath(os.path.join(os.path.abspath(__file__), "../..")) 13 | if root_path not in sys.path: 14 | sys.path.append(root_path) 15 | 16 | # The inputs section will change based on the arguments of the tool function, after you save the code 17 | # Adding type to arguments and return value will help the system show the types properly 18 | # Please update the function name/signature per need 19 | @tool 20 | def parse_response(response_msgs, response_type: Enum): 21 | from prompts.prompt_utils import parse_chunks_by_separators, match_code_block 22 | 23 | content = response_msgs[-1]['content'] 24 | 25 | if response_type == ResponseType.CONTENT: 26 | return content 27 | elif response_type == ResponseType.CODEBLOCK: 28 | codeblock = match_code_block(content) 29 | 30 | if codeblock: 31 | return codeblock[-1] 32 | else: 33 | raise Exception("无法解析回答,未包含三引号代码块。") 34 | 35 | elif response_type == ResponseType.SEPARATORS: 36 | chunks = parse_chunks_by_separators(content, [r'\S*', ]) 37 | return chunks 38 | else: 39 | raise Exception(f"无效的解析类型:{response_type}") 40 | -------------------------------------------------------------------------------- /prompts/tool_polish.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from promptflow.core import tool, load_flow 3 | 4 | import sys, os 5 | root_path = os.path.abspath(os.path.join(os.path.abspath(__file__), "../..")) 6 | if root_path not in sys.path: 7 | sys.path.append(root_path) 8 | 9 | 10 | @tool 11 | def polish(messages, context, model, config, text): 12 | source = path.join(path.dirname(path.abspath(__file__)), "./polish") 13 | flow = load_flow(source=source) 14 | 15 | return flow( 16 | chat_messages=messages, 17 | context=context, 18 | model=model, 19 | config=config, 20 | text=text, 21 | ) 22 | 23 | -------------------------------------------------------------------------------- /prompts/创作剧情/context_prompt.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 多轮对话,每轮对话中输入一个信息,这样设计为了Prompt Caching 3 | // 中括号{}表示变量,会自动填充为对应值。 4 | 5 | 6 | user: 7 | 下面是**章节大纲**。 8 | 9 | **章节大纲** 10 | {chapter} 11 | 12 | assistant: 13 | 收到,我会参考章节大纲进行剧情的创作。 14 | 15 | 16 | user: 17 | 下面是**剧情上下文**,用于在创作时进行参考。 18 | 19 | **剧情上下文** 20 | {context_y} 21 | 22 | 23 | assistant: 24 | 收到,我在创作时需要考虑到和前后上下文的连贯。 25 | 26 | 27 | user: 28 | 下面是**剧情**,需要你重新创作的部分。 29 | 30 | **剧情** 31 | {y} 32 | 33 | assistant: 34 | 收到,这部分剧情我会重新创作。 35 | 36 | -------------------------------------------------------------------------------- /prompts/创作剧情/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.baseprompt import main as base_main 3 | from core.writer_utils import split_text_into_sentences 4 | 5 | def format_plot(text): 6 | text = text.replace('\n', '') 7 | sentences = split_text_into_sentences(text, keep_separators=True) 8 | return "\n".join(sentences) 9 | 10 | def main(model, user_prompt, **kwargs): 11 | dirname = os.path.dirname(__file__) 12 | 13 | if 'context_y' in kwargs and 'y' in kwargs and kwargs['context_y'] == kwargs['y']: 14 | kwargs['context_y'] = '参考**剧情**' 15 | 16 | if 'context_x' in kwargs and 'x' in kwargs and kwargs['context_x'] == kwargs['x']: 17 | kwargs['context_x'] = '参考**章节大纲**' 18 | 19 | for ret in base_main(model, dirname, user_prompt, **kwargs): 20 | # ret['text'] = format_plot(ret['text']) 21 | yield ret 22 | 23 | return ret 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /prompts/创作剧情/system_prompt.txt: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说大神作家,正在创作小说剧情,你需要根据**章节大纲**创作对应的章节剧情,并积极响应用户意见来修改剧情。 4 | 5 | 6 | **剧情格式** 7 | 1. 每行一句话,在50字以内,描述一个关键场景或情节转折 8 | 2. 不能有任何标题,序号,分点等 9 | 3. 关注行为、事件、伏笔、冲突、转折、高潮等对剧情有重大影响的内容 10 | 4. 不进行细致的环境、心理、外貌、语言描写 11 | 5. 在三引号(```)文本块中创作剧情 12 | -------------------------------------------------------------------------------- /prompts/创作剧情/扩写剧情.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // chapter, context_y, y 5 | // chapter:章节大纲,用于在创作时进行参考 6 | // context_y:剧情上下文,用于保证前后上下文的连贯 7 | // y:即要重新创作的剧情(片段) 8 | 9 | user: 10 | **剧情**需要有更丰富的内容,在剧情中间引入更多事件,使其变得一波三折、跌宕起伏,使得读来更有故事性。 11 | 12 | 按以下步骤输出: 13 | 1. 思考 14 | 2. 在三引号中创作对应的剧情 -------------------------------------------------------------------------------- /prompts/创作剧情/新建剧情.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:chapter 5 | // chapter:章节大纲,用于在创作时进行参考 6 | 7 | 8 | user: 9 | 你需要参考**章节大纲**,创作对应的剧情。 10 | 11 | 按下面步骤输出: 12 | 1. 思考将章节大纲中的情节扩展为一个完整的故事 13 | 2. 在三引号中创作对应的剧情 -------------------------------------------------------------------------------- /prompts/创作剧情/格式化剧情.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:y 5 | // y:即要格式化的剧情,可以是整个剧情,也可以是其中的一部分 6 | 7 | user: 8 | 你需要将**剧情**转化为正确的小说剧情格式,遵循以下规则: 9 | 10 | 1. 每行一句话,在50字以内,描述一个关键场景或情节转折 11 | 2. 对于环境、心理、外貌、语言描写,一笔带过 12 | 3. 删去意义不明、不是剧情的句子 13 | 4. 删去重复的剧情 14 | 5. 删去标题、序号 15 | 16 | 6. 如果输入的已经是剧情格式,保持原样输出。 17 | 7. 如果输入的不是剧情格式,请将其转化为上述剧情格式。 18 | 19 | 请对**剧情**进行处理,在三引号(```)文本块中输出符合要求的剧情格式。 -------------------------------------------------------------------------------- /prompts/创作剧情/润色剧情.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // chapter, context_y, y 5 | // chapter:章节大纲,用于在创作时进行参考 6 | // context_y:剧情上下文,用于保证前后上下文的连贯 7 | // y:即要重新创作的剧情(片段) 8 | 9 | 10 | user: 11 | **剧情**中的情节需要更加流畅、合理。 12 | 13 | 按以下步骤输出: 14 | 1. 思考 15 | 2. 在三引号中创作对应的剧情 16 | 17 | 18 | -------------------------------------------------------------------------------- /prompts/创作正文/context_prompt.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 多轮对话,每轮对话中输入一个信息,这样设计为了Prompt Caching 3 | // 中括号{}表示变量,会自动填充为对应值。 4 | 5 | 6 | user: 7 | 下面是**剧情上下文**,用于在创作时进行参考。 8 | 9 | **剧情上下文** 10 | {context_x} 11 | 12 | assistant: 13 | 收到,我在创作时需要考虑到和前后上下文的连贯。 14 | 15 | 16 | user: 17 | 下面是**正文上下文**,用于在创作时进行参考。 18 | 19 | **正文上下文** 20 | {context_y} 21 | 22 | assistant: 23 | 收到,我在创作时需要考虑到和前后上下文的连贯。 24 | 25 | 26 | user: 27 | 下面是**剧情**。 28 | 29 | **剧情** 30 | {x} 31 | 32 | assistant: 33 | 收到,我会参考剧情进行创作。 34 | 35 | 36 | user: 37 | 下面是**正文**,需要你重新创作的部分。 38 | 39 | **正文** 40 | {y} 41 | 42 | assistant: 43 | 收到,这部分正文我会重新创作。 44 | 45 | -------------------------------------------------------------------------------- /prompts/创作正文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.baseprompt import main as base_main 3 | 4 | 5 | 6 | def main(model, user_prompt, **kwargs): 7 | dirname = os.path.dirname(__file__) 8 | 9 | if 'context_y' in kwargs and 'y' in kwargs and kwargs['context_y'] == kwargs['y']: 10 | kwargs['context_y'] = '参考**正文**' 11 | 12 | if 'context_x' in kwargs and 'x' in kwargs and kwargs['context_x'] == kwargs['x']: 13 | kwargs['context_x'] = '参考**剧情**' 14 | 15 | ret = yield from base_main(model, dirname, user_prompt, **kwargs) 16 | 17 | return ret 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /prompts/创作正文/system_prompt.txt: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说大神作家,正在创作小说正文,你需要根据**剧情**创作对应的正文,并积极响应用户意见来修改正文。 4 | 5 | 6 | **正文格式** 7 | 1. 严格参考剧情进行叙述,剧情中每一行对应正文中一到多行 8 | 2. 正文不能有标题,不能有序号 9 | 3. 使用第三人称视角,按时间顺序展开叙述 10 | 4. 语言需要简洁且富有张力,避免长篇累牍 11 | 5. 用环境、心理、外貌、语言、行为描写去推送故事情节 12 | 6. 在三引号(```)文本块中创作正文 -------------------------------------------------------------------------------- /prompts/创作正文/扩写正文.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:context_x, context_y, x, y 5 | // context_x:剧情上下文,用于在创作时进行参考 6 | // context_y:正文上下文,用于保证前后上下文的连贯 7 | // x: 要创作的正文对应的剧情(片段) 8 | // y:即要重新创作的正文(片段) 9 | 10 | user: 11 | **正文**的描写需要更加细致,加入更多的场景刻画、人物、语言、行为、心理描写等。 12 | 13 | 按以下步骤输出: 14 | 1. 思考 15 | 2. 在三引号中创作对应的正文 -------------------------------------------------------------------------------- /prompts/创作正文/新建正文.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:context_x, x 5 | // context_x:剧情上下文,用于在创作时进行参考 6 | // x: 要创作的正文对应的剧情(片段) 7 | 8 | 9 | user: 10 | 你需要参考**剧情**,创作对应的正文。 11 | 12 | 按下面步骤输出: 13 | 1. 思考将剧情中的情节扩展为一个完整的故事 14 | 2. 在三引号中创作对应正文 -------------------------------------------------------------------------------- /prompts/创作正文/格式化正文.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:y 5 | // y:即要格式化的正文,可以是整个正文,也可以是其中的一部分 6 | 7 | user: 8 | 你需要将**正文**转化为正确的小说正文格式,遵循以下规则: 9 | 10 | 1. 严格参考剧情进行叙述,剧情中每一行对应正文中一到多行 11 | 2. 避免出现长段落,主动换行 12 | 3. 人物对话要用引号 13 | 4. 保持统一的叙述视角 14 | 5. 删去意义不明、不是正文的句子 15 | 6. 删去重复的正文 16 | 7. 删去标题和序号 17 | 18 | 8. 如果输入的已经是正文格式,保持原样输出。 19 | 9. 如果输入的不是正文格式,请将其转化为上述正文格式。 20 | 21 | 请对**正文**进行处理,在三引号(```)文本块中输出符合要求的正文格式。 -------------------------------------------------------------------------------- /prompts/创作正文/润色正文.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:context_x, context_y, x, y 5 | // context_x:剧情上下文,用于在创作时进行参考 6 | // context_y:正文上下文,用于保证前后上下文的连贯 7 | // x: 要创作的正文对应的剧情(片段) 8 | // y:即要重新创作的正文(片段) 9 | 10 | user: 11 | **正文**中的叙述需要更加流畅、得体。 12 | 13 | 按以下步骤输出: 14 | 1. 思考 15 | 2. 在三引号中创作对应的正文 16 | 17 | -------------------------------------------------------------------------------- /prompts/创作章节/context_prompt.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 多轮对话,每轮对话中输入一个信息,这样设计为了Prompt Caching 3 | // 中括号{}表示变量,会自动填充为对应值。 4 | 5 | user: 6 | 下面是**小说简介**。 7 | 8 | **小说简介** 9 | {summary} 10 | 11 | assistant: 12 | 收到,我会参考小说简介进行创作。 13 | 14 | 15 | user: 16 | 下面是**章节上下文**,用于在创作时进行参考。 17 | 18 | **章节上下文** 19 | {context_y} 20 | 21 | assistant: 22 | 收到,我在创作时需要考虑到和前后章节的连贯。 23 | 24 | 25 | user: 26 | 下面是**章节**,需要你重新创作的部分。 27 | 28 | **章节** 29 | {y} 30 | 31 | assistant: 32 | 收到,这部分章节我会重新创作。 33 | 34 | -------------------------------------------------------------------------------- /prompts/创作章节/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.baseprompt import main as base_main 3 | from core.writer_utils import split_text_into_sentences 4 | 5 | def format_outline(text): 6 | text = text.replace('\n', '') 7 | sentences = split_text_into_sentences(text, keep_separators=True) 8 | return "\n".join(sentences) 9 | 10 | 11 | def main(model, user_prompt, **kwargs): 12 | dirname = os.path.dirname(__file__) 13 | 14 | if 'context_y' in kwargs and 'y' in kwargs and kwargs['context_y'] == kwargs['y']: 15 | kwargs['context_y'] = '参考**章节**' 16 | 17 | for ret in base_main(model, dirname, user_prompt, **kwargs): 18 | # ret['text'] = format_outline(ret['text']) 19 | yield ret 20 | 21 | return ret 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /prompts/创作章节/system_prompt.txt: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说大神作家,正在创作小说章节大纲,你需要根据**小说简介**创作对应的章节大纲,并积极响应用户意见来修改章节。 4 | 5 | 6 | **章节格式** 7 | 1. 每章的开头是单独一行的标题,用于指明章节序号和名称,例如:第17章 问情 8 | 2. 可以创作多章,每章开头要有标题,每章内容是该章的剧情纲要 9 | 3. 不要进行环境、外貌、语言、心理描写,也不要描述具体行为。关注大的事件。 10 | 4. 在三引号(```)文本块中创作章节 11 | -------------------------------------------------------------------------------- /prompts/创作章节/扩写章节.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:summary, context_y, y 5 | // summary:小说简介,用于在创作时进行参考 6 | // context_y:章节上下文,用于保证前后上下文的连贯 7 | // y:即要重新创作的章节,可以是整个章节,也可以是其中的一部分 8 | 9 | user: 10 | **章节**需要有更丰富的内容,在章节中间引入更多事件,使其变得一波三折、跌宕起伏,使得读来更有故事性。 11 | 12 | 按以下步骤输出: 13 | 1. 思考 14 | 2. 在三引号中创作对应的章节 -------------------------------------------------------------------------------- /prompts/创作章节/新建章节.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:summary 5 | // summary:小说简介,用于在创作时进行参考 6 | 7 | // 新建章节不输入context_y和y,所以总是从头开始生成 8 | // 新建章节输出完整的章节,而不是章节片段 9 | 10 | user: 11 | 你需要参考**小说简介**,创作小说的章节。 12 | 13 | 按下面步骤输出: 14 | 1. 思考小说的故事结构 15 | 2. 在三引号中创作小说的章节 -------------------------------------------------------------------------------- /prompts/创作章节/格式化章节.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:y 5 | // y:即要格式化的章节,可以是整个章节,也可以是其中的一部分 6 | 7 | user: 8 | 你需要将**章节**转化为正确的小说章节格式,遵循以下规则: 9 | 10 | 1. 每章的开头是单独一行,用于指明章节序号和名称,例如:第17章 问情 11 | 2. 可以创作多章,每章开头要有标题,每章内容是该章的剧情纲要 12 | 3. 不要进行环境、外貌、语言、心理描写,也不要描述具体行为。关注大的事件。 13 | 14 | 4. 如果输入的已经是章节格式,保持原样输出。 15 | 5. 如果输入的不是章节格式,请将其转化为上述章节格式。 16 | 17 | 请对**章节**进行处理,在三引号(```)文本块中输出符合要求的章节格式。 -------------------------------------------------------------------------------- /prompts/创作章节/润色章节.txt: -------------------------------------------------------------------------------- 1 | // 双斜杠开头是注释,不会输入到大模型 2 | // 文件开头结尾的空行会被忽略 3 | 4 | // 输入:summary, context_y, y 5 | // summary:小说简介,用于在创作时进行参考 6 | // context_y:章节上下文,用于保证前后上下文的连贯 7 | // y:即要重新创作的章节,可以是整个章节,也可以是其中的一部分 8 | 9 | user: 10 | **章节**的结构需要更加流畅、合理。 11 | 12 | 按以下步骤输出: 13 | 1. 思考 14 | 2. 在三引号中创作对应的章节 -------------------------------------------------------------------------------- /prompts/审阅/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from prompts.chat_utils import chat, log 4 | from prompts.baseprompt import parse_prompt, load_prompt 5 | 6 | 7 | def main(model, prompt_name, **kwargs): 8 | assert 'y' in kwargs, 'y must in kwargs' 9 | 10 | dirname = os.path.dirname(__file__) 11 | 12 | messages = parse_prompt(load_prompt(dirname, prompt_name), **kwargs) 13 | 14 | for response_msgs in chat(messages, None, model, parse_chat=False): 15 | text = response_msgs.response 16 | ret = {'text': text, 'response_msgs': response_msgs} 17 | yield ret 18 | 19 | return ret 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /prompts/审阅/审阅剧情.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 现在你是一个网文主编,正在审稿。 3 | 4 | 稿件的要求是一段剧情片段设计,具体如下: 5 | 1. 稿件内容为一段网文剧情,需要专注于叙述事件,不刻画场景、不进行细致描写 6 | 2. 考核剧情片段的结构和情节设计 7 | 3. 不评判格式、文笔、描写等,也不应出现这些非剧情内容 8 | 9 | 在回复时,分几个点,每个点的内容需要简明扼要,一针见血。 10 | 11 | 考虑下面角度: 12 | 1. 是否符合剧情格式?描写是否过于具体? 13 | 2. 情节推动是否过快或过慢?是否过于平淡? 14 | 3. ... 15 | 16 | 下面是一个作者提交的剧情片段,请进行审阅: 17 | 18 | user: 19 | {y} -------------------------------------------------------------------------------- /prompts/审阅/审阅大纲.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 现在你是一个网文主编,正在审稿。 3 | 4 | 稿件的要求是全书大纲,具体如下: 5 | 1. 稿件内容为全书大纲,需要专注于整本书的故事主线。 6 | 2. 考核整本书的人物塑造、故事结构、价值观内核。 7 | 3. 不评判格式、文笔、描写等,也不应出现这些非大纲内容。 8 | 9 | 10 | 在回复时,分几个点,每个点的内容需要简明扼要,一针见血。 11 | 12 | 考虑下面角度: 13 | 1. 是否符合大纲格式? 14 | 2. ... 15 | 16 | 下面是一个作者提交的全书大纲,请进行审阅: 17 | 18 | user: 19 | {y} -------------------------------------------------------------------------------- /prompts/审阅/审阅正文.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 现在你是一个网文主编,正在审稿。 3 | 4 | 在审稿时,由于作者只提交了一个正文片段,所以只评判该片段的格式、文笔、画面感,不评判结构和情节。 5 | 6 | 在回复时,分几个点,每个点的内容需要简明扼要,一针见血。 7 | 8 | 考虑下面角度: 9 | 1. 场景描写是否够具体?是否有画面感。 10 | 2. 人物刻画如何?是否有外貌、动作、心理、语言等细节描写。 11 | 3. ... 12 | 13 | 下面是一个作者提交的网文片段,请进行审阅: 14 | 15 | user: 16 | {y} -------------------------------------------------------------------------------- /prompts/对齐剧情和正文/prompt.jinja2: -------------------------------------------------------------------------------- 1 | user: 2 | ###任务 3 | 我会给你小说的剧情和正文,需要你将剧情和正文对应上。 4 | 5 | ###剧情 6 | {% for chunk in plot_chunks %} 7 | ({{ loop.index }}){{ chunk }} 8 | {% endfor %} 9 | 10 | ###正文 11 | {% for chunk in text_chunks %} 12 | ({{ loop.index }}){{ chunk }} 13 | {% endfor %} 14 | 15 | ###输出格式 16 | //以JSON格式输出 17 | { 18 | "1": [1, 2, ...], //在列表中依次填写剧情段1对应的一个或多个连续的正文段序号 19 | "2": [...], //在列表中依次填写剧情段2对应的一个或多个连续的正文段序号 20 | ... //对每个剧情段都需要填写其对应的正文段序号,每个序号只能提及一次 21 | } 22 | -------------------------------------------------------------------------------- /prompts/对齐剧情和正文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import parse_chunks_by_separators, match_code_block, load_jinja2_template 4 | 5 | import json 6 | import numpy as np 7 | 8 | 9 | def parser(response_msgs, plot_chunks, text_chunks): 10 | from prompts.prompt_utils import match_first_json_block 11 | content = response_msgs[-1]['content'] 12 | content = match_first_json_block(content) 13 | plot2text = json.loads(content) 14 | 15 | plot2text = {int(k) - 1 : [e - 1 for e in v] for k, v in plot2text.items()} 16 | # print(plot2text) 17 | plot_text_pair = [] 18 | 19 | # ploti_l = np.array(list(plot2text.keys())) 20 | # textl_l = np.array([e[0] for e in plot2text.values()]) 21 | 22 | # if not (np.all(ploti_l[1:] >= ploti_l[:-1]) and np.all(textl_l[1:] >= textl_l[:-1])): 23 | # return [] 24 | 25 | # if not (ploti_l[0] == 0 and textl_l[0] == 0): 26 | # return [] 27 | 28 | if 0 not in plot2text or plot2text[0] != 0: 29 | plot2text[0] = [0, ] 30 | 31 | for ploti in range(len(plot_chunks)): 32 | if ploti not in plot2text or not plot2text[ploti]: 33 | plot_text_pair[-1][0].append(ploti) 34 | else: 35 | textl = min(plot2text[ploti][0], len(text_chunks)-1) 36 | if ploti > 0: 37 | if plot_text_pair[-1][1][0] == textl: 38 | plot_text_pair[-1][0].append(ploti) 39 | continue 40 | elif plot_text_pair[-1][1][0] > textl: 41 | plot_text_pair[-1][0].append(ploti) 42 | continue 43 | else: 44 | plot_text_pair[-1][1].extend(range(plot_text_pair[-1][1][0] + 1, textl)) 45 | plot_text_pair.append(([ploti, ], [textl, ])) 46 | 47 | plot_text_pair[-1][1].extend(range(plot_text_pair[-1][1][0] + 1, len(text_chunks))) 48 | 49 | return plot_text_pair 50 | 51 | 52 | def main(model, plot_chunks, text_chunks): 53 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 54 | 55 | prompt = template.render(plot_chunks=plot_chunks, 56 | text_chunks=text_chunks) 57 | 58 | for response_msgs in chat([], prompt, model, parse_chat=True, response_json=True): 59 | yield {'plot2text': {}, 'response_msgs': response_msgs} 60 | 61 | plot2text = parser(response_msgs, plot_chunks, text_chunks) 62 | 63 | return {'plot2text': plot2text, 'response_msgs':response_msgs} 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /prompts/提炼/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from prompts.chat_utils import chat, log 4 | from prompts.baseprompt import parse_prompt, load_prompt 5 | from prompts.common_parser import parse_last_code_block as parser 6 | 7 | 8 | def main(model, user_prompt, **kwargs): 9 | assert 'y' in kwargs, 'y must in kwargs' 10 | 11 | dirname = os.path.dirname(__file__) 12 | 13 | messages = parse_prompt(load_prompt(dirname, user_prompt), **kwargs) 14 | 15 | for response_msgs in chat(messages, None, model, parse_chat=False): 16 | text = parser(response_msgs) 17 | ret = {'text': text, 'response_msgs': response_msgs, 'text_key': 'x_chunk'} 18 | yield ret 19 | 20 | return ret 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /prompts/提炼/提炼剧情.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 你需要参考一段小说的正文,提炼出对应的剧情。 3 | 4 | 在提炼剧情时,需要遵照以下原则: 5 | 1. 提炼的剧情和正文有一一对应,每行一句话,在50字以内,对应正文中一个关键场景或情节转折 6 | 2. 严格参照正文来提炼剧情,不能擅自延申、改编、删减,更不能在结尾进行总结、推演、展望 7 | 3. 不能有任何标题,序号,分点等 8 | 4. 对环境、心理、外貌、语言描写进行简化/概括 9 | 5. 在三引号(```)文本块中输出对应的剧情 10 | 11 | 12 | user: 13 | 下面是一段正文,需要提炼出对应的剧情: 14 | {y} -------------------------------------------------------------------------------- /prompts/提炼/提炼大纲.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 你需要参考小说的章节,提炼出小说大纲。 3 | 4 | 在提炼小说大纲时,需要遵照以下原则: 5 | 1. 关注整个小说的故事脉络,对故事进行提取并总结。 6 | 2. 不要逐章总结,关注整体 7 | 2. 在三引号(```)文本块中输出小说大纲 8 | 9 | 10 | user: 11 | 下面是小说章节,需要提炼出小说大纲: 12 | {y} -------------------------------------------------------------------------------- /prompts/提炼/提炼章节.txt: -------------------------------------------------------------------------------- 1 | system: 2 | 你需要参考一段小说的章节剧情,提炼出章节大纲。 3 | 4 | 在提炼章节大纲时,需要遵照以下原则: 5 | 1. 不能简单的总结,需要关注章节剧情中事件的脉络(起因、经过、高潮、结果),对事件进行提取并总结 6 | 2. 忽略不重要的细节(例如:环境、外貌、语言、心理描写 7 | 3. 不能有任何标题,序号,分点等 8 | 4. 在三引号(```)文本块中输出对应的章节大纲 9 | 10 | 11 | user: 12 | 下面是一段小说的章节剧情,需要提炼出章节大纲: 13 | {y} -------------------------------------------------------------------------------- /prompts/根据意见重写剧情/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说作家,正在创作小说剧情,你需要在原剧情基础上进行创作,使得原剧情更加丰富、完善,并积极响应用户意见来修改剧情。 4 | 5 | 注意: 6 | 1. 只创作剧情,而不是正文,思考并完善故事情节发展 7 | 2. 输出由一个个短句组成,每个短句在50字以内,每个短句占一行。 8 | 9 | 例子1: 10 | **剧情** 11 | 李珣呆立不动,背后传来水声,那雾中的女子在悠闲洗浴。 12 | 李珣对这种场景感到震惊,认为她绝非普通人,决定乖乖表现。 13 | 尽管转身,他仍紧闭眼睛,慌乱道歉。 14 | 那女子静默片刻,继续泼水声令李珣难以忍受。 15 | 她随后淡然问话,李珣感到对方危险可怕。 16 | 17 | 18 | 例子2: 19 | **剧情** 20 | 纳兰嫣然出现,父女二人开战言语。纳兰肃火冒三丈,纳兰嫣然反对重新接触萧炎,认为只有她自己可以决定自己的婚事。 21 | 纳兰肃怒斥,纳兰嫣然强硬回击,表示她不会道歉,只会等待萧炎挑战她。如果她输了,愿意为奴为婢,但她相信自己不会输。 22 | 23 | 24 | user: 25 | 在原剧情的基础上进行创作。 26 | 27 | **原剧情** 28 | ``` 29 | {{context_x}} 30 | ``` 31 | 32 | assistant: 33 | 好的,这是创作的剧情。 34 | ``` 35 | {{context_y}} 36 | ``` 37 | 38 | 39 | user: 40 | 你创作的剧情大致是对的,但其中的某个片段需要修改,请根据我的意见,对剧情片段进行修改。 41 | 42 | 43 | **意见** 44 | {{suggestion}} 45 | 46 | 47 | **剧情片段** 48 | ``` 49 | {{y}} 50 | ``` 51 | 52 | 53 | **输出格式** 54 | 思考: 55 | (根据意见进行思考) 56 | 57 | 58 | ``` 59 | (在这个三引号块中输出根据意见修改后的剧情片段) 60 | ``` 61 | -------------------------------------------------------------------------------- /prompts/根据意见重写剧情/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat, log 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_last_code_block 5 | from core.writer_utils import split_text_into_sentences 6 | 7 | def parser(response_msgs): 8 | text = parse_last_code_block(response_msgs) 9 | text = text.replace('\n', '') 10 | sentences = split_text_into_sentences(text, keep_separators=True) 11 | return "\n".join(sentences) 12 | 13 | 14 | def main(model, context_x, context_y, y, suggestion): 15 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 16 | 17 | prompt = template.render(context_x=context_x, 18 | context_y=context_y, 19 | y=y, 20 | suggestion=suggestion,) 21 | 22 | for response_msgs in chat([], prompt, model, parse_chat=True): 23 | newtext = parser(response_msgs) 24 | ret = {'text': newtext, 'response_msgs': response_msgs} 25 | yield ret 26 | 27 | log('根据意见重写剧情', prompt, ret) 28 | 29 | return ret 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /prompts/根据意见重写正文/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说作家,正在创作小说正文,你需要严格参考剧情进行创作正文,并积极响应用户意见来修改正文。 4 | 5 | 注意: 6 | 1. 创作的是小说正文,像一个小说家那样去描写整个故事 7 | 2. 需要加入人物的外貌、行为、语言描写,以及环境描写等。 8 | 3. 正文和剧情一一对应 9 | 10 | 例子1: 11 | **剧情** 12 | 李珣呆立不动,背后传来水声,那雾中的女子在悠闲洗浴。 13 | 李珣对这种场景感到震惊,认为她绝非普通人,决定乖乖表现。 14 | 尽管转身,他仍紧闭眼睛,慌乱道歉。 15 | 那女子静默片刻,继续泼水声令李珣难以忍受。 16 | 她随后淡然问话,李珣感到对方危险可怕。 17 | 18 | 19 | **正文** 20 | 李珣呆立当场,手足无措。 21 | 后方水声不止,那位雾后佳人并未停下动作,还在那里撩水净身。 22 | 李珣听得有些傻了,虽然他对异性的认识不算全面,可是像后面这位,能够在男性身旁悠闲沐浴的,是不是也稀少了一些? 23 | 李珣毕竟不傻,他此时也已然明白,现在面对的是一位绝对惹不起的人物,在这种强势人物眼前,做一个乖孩子,是最聪明不过的了! 24 | 他虽已背过身来,却还是紧闭眼睛,生怕无意间又冒犯了人家,这无关道德风化,仅仅是为了保住小命而已。 25 | 确认了一切都已稳妥,他这才结结巴巴地开口:“对……对不住,我不是……故意的!” 26 | 对方并没有即时回答,李珣只听到哗哗的泼水声,每一点声息,都是对他意志的摧残。 27 | 也不知过了多久,雾后的女子开口了:“话是真的,却何必故作紧张?事不因人而异,一个聪明人和一个蠢材,要承担的后果都是一样的。” 28 | 李珣顿时哑口无言。 29 | 后面这女人,实在太可怕了。 30 | 31 | 32 | 例子2: 33 | **剧情** 34 | 纳兰嫣然出现,父女二人开战言语。纳兰肃火冒三丈,纳兰嫣然反对重新接触萧炎,认为只有她自己可以决定自己的婚事。 35 | 纳兰肃怒斥,纳兰嫣然强硬回击,表示她不会道歉,只会等待萧炎挑战她。如果她输了,愿意为奴为婢,但她相信自己不会输。 36 | 37 | 38 | **正文** 39 | 就在纳兰肃心头发怒之时,女子清脆的声音,忽然地在大厅内响起,月白色的倩影,从纱帘中缓缓行出,对着纳兰肃甜甜笑道。 40 | “哼,你眼里还有我这个父亲?我以为你成为了云韵的弟子,就不知道什么是纳兰家族了呢!”望着这出落得越来越水灵的女儿,纳兰肃心头的怒火稍稍收敛了一点,冷哼道。 41 | 瞧着纳兰肃不甚好看的脸色,纳兰嫣然无奈地摇了摇头,对着那一旁的侍女挥了挥手,将之遣出。 42 | “父亲,一年多不见,你一来就训斥焉儿,等下次回去,我可一定要告诉母亲!”待得侍女退出之后,纳兰嫣然顿时皱起了俏鼻,在纳兰肃身旁坐下,撒娇般的哼道。 43 | “回去?你还敢回去?”闻言,纳兰肃嘴角一裂:“你敢回去,看你爷爷敢不敢打断你的腿……” 44 | 撇了撇嘴,心知肚明的纳兰嫣然,自然清楚纳兰肃话中的意思。 45 | “你应该知道我来此处的目的吧?” 46 | 狠狠的灌了一口茶水,纳兰肃阴沉着脸道。 47 | “是为了我悔婚的事吧?” 48 | 纤手把玩着一缕青丝,纳兰嫣然淡淡地道。 49 | 看着纳兰嫣然这平静的模样,纳兰肃顿时被气乐了,手掌重重地拍在桌上,怒声道:“婚事是你爷爷当年亲自允下的,是谁让你去解除的?” 50 | “那是我的婚事,我才不要按照你们的意思嫁给谁,我的事,我自己会做主!我不管是谁允下的,我只知道,如果按照约定。嫁过去的是我,不是爷爷!”提起这事,纳兰嫣然也是脸现不愉,性子有些独立的她,很讨厌自己的大事按照别人所指定的路线行走。即使这人是她的长辈。 51 | “你别以为我不知道,你无非是认为萧炎当初一个废物配不上你是吧?可现在人家潜力不会比你低!以你在云岚宗的地位,应该早就接到过有关他实力提升的消息吧?”纳兰肃怒道。 52 | 纳兰嫣然黛眉微皱,脑海中浮现当年那充满着倔性的少年,红唇微抿,淡淡地道:“的确听说过一些关于他的消息,没想到,他竟然还真的能脱去废物的名头,这倒的确让我很意外。” 53 | “意外?一句意外就行了?你爷爷开口了。让你找个时间,再去一趟乌坦城,最好能道个歉把僵硬的关系弄缓和一些。”纳兰肃皱眉道。 54 | “道歉?不可能!” 55 | 闻言,纳兰嫣然柳眉一竖,毫不犹豫地直接拒绝,冷哼道:“他萧炎虽然不再是废物,可我纳兰嫣然依然不会嫁给他!更别提让我去道什么歉,你们喜欢,那就自己去,反正我不会再去乌坦城!” 56 | 57 | 58 | user: 59 | 参考剧情进行创作。 60 | 61 | **剧情** 62 | ``` 63 | {{context_x}} 64 | ``` 65 | 66 | assistant: 67 | 好的,这是参考剧情创作的正文。 68 | ``` 69 | {{context_y}} 70 | ``` 71 | 72 | user: 73 | 你创作的正文大致是对的,但其中的某个片段需要修改,请根据我的意见,对正文片段进行修改。 74 | 75 | 76 | **意见** 77 | {{suggestion}} 78 | 79 | 80 | **正文片段** 81 | ``` 82 | {{y}} 83 | ``` 84 | 85 | 86 | **输出格式** 87 | ``` 88 | (在这个三引号块中输出根据意见修改后的正文片段) 89 | ``` 90 | -------------------------------------------------------------------------------- /prompts/根据意见重写正文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat, log 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_last_code_block as parser 5 | 6 | 7 | 8 | def main(model, context_x, context_y, y, suggestion): 9 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 10 | 11 | prompt = template.render(context_x=context_x, 12 | context_y=context_y, 13 | y=y, 14 | suggestion=suggestion,) 15 | 16 | for response_msgs in chat([], prompt, model, parse_chat=True): 17 | newtext = parser(response_msgs) 18 | ret = {'text': newtext, 'response_msgs': response_msgs} 19 | yield ret 20 | 21 | log('根据意见重写正文', prompt, ret) 22 | 23 | return ret 24 | 25 | 26 | -------------------------------------------------------------------------------- /prompts/根据提纲创作正文/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个小说作家,正在创作小说正文,你需要严格参考剧情进行创作正文,并积极响应用户意见来修改正文。 4 | 5 | 注意: 6 | 1. 创作的是小说正文,像一个小说家那样去描写整个故事 7 | 2. 需要加入人物的外貌、行为、语言描写,以及环境描写等。 8 | 3. 正文和剧情一一对应 9 | 10 | 例子1: 11 | **剧情** 12 | 李珣呆立不动,背后传来水声,那雾中的女子在悠闲洗浴。 13 | 李珣对这种场景感到震惊,认为她绝非普通人,决定乖乖表现。 14 | 尽管转身,他仍紧闭眼睛,慌乱道歉。 15 | 那女子静默片刻,继续泼水声令李珣难以忍受。 16 | 她随后淡然问话,李珣感到对方危险可怕。 17 | 18 | 19 | **正文** 20 | 李珣呆立当场,手足无措。 21 | 后方水声不止,那位雾后佳人并未停下动作,还在那里撩水净身。 22 | 李珣听得有些傻了,虽然他对异性的认识不算全面,可是像后面这位,能够在男性身旁悠闲沐浴的,是不是也稀少了一些? 23 | 李珣毕竟不傻,他此时也已然明白,现在面对的是一位绝对惹不起的人物,在这种强势人物眼前,做一个乖孩子,是最聪明不过的了! 24 | 他虽已背过身来,却还是紧闭眼睛,生怕无意间又冒犯了人家,这无关道德风化,仅仅是为了保住小命而已。 25 | 确认了一切都已稳妥,他这才结结巴巴地开口:“对……对不住,我不是……故意的!” 26 | 对方并没有即时回答,李珣只听到哗哗的泼水声,每一点声息,都是对他意志的摧残。 27 | 也不知过了多久,雾后的女子开口了:“话是真的,却何必故作紧张?事不因人而异,一个聪明人和一个蠢材,要承担的后果都是一样的。” 28 | 李珣顿时哑口无言。 29 | 后面这女人,实在太可怕了。 30 | 31 | 32 | 例子2: 33 | **剧情** 34 | 纳兰嫣然出现,父女二人开战言语。纳兰肃火冒三丈,纳兰嫣然反对重新接触萧炎,认为只有她自己可以决定自己的婚事。 35 | 纳兰肃怒斥,纳兰嫣然强硬回击,表示她不会道歉,只会等待萧炎挑战她。如果她输了,愿意为奴为婢,但她相信自己不会输。 36 | 37 | 38 | **正文** 39 | 就在纳兰肃心头发怒之时,女子清脆的声音,忽然地在大厅内响起,月白色的倩影,从纱帘中缓缓行出,对着纳兰肃甜甜笑道。 40 | “哼,你眼里还有我这个父亲?我以为你成为了云韵的弟子,就不知道什么是纳兰家族了呢!”望着这出落得越来越水灵的女儿,纳兰肃心头的怒火稍稍收敛了一点,冷哼道。 41 | 瞧着纳兰肃不甚好看的脸色,纳兰嫣然无奈地摇了摇头,对着那一旁的侍女挥了挥手,将之遣出。 42 | “父亲,一年多不见,你一来就训斥焉儿,等下次回去,我可一定要告诉母亲!”待得侍女退出之后,纳兰嫣然顿时皱起了俏鼻,在纳兰肃身旁坐下,撒娇般的哼道。 43 | “回去?你还敢回去?”闻言,纳兰肃嘴角一裂:“你敢回去,看你爷爷敢不敢打断你的腿……” 44 | 撇了撇嘴,心知肚明的纳兰嫣然,自然清楚纳兰肃话中的意思。 45 | “你应该知道我来此处的目的吧?” 46 | 狠狠的灌了一口茶水,纳兰肃阴沉着脸道。 47 | “是为了我悔婚的事吧?” 48 | 纤手把玩着一缕青丝,纳兰嫣然淡淡地道。 49 | 看着纳兰嫣然这平静的模样,纳兰肃顿时被气乐了,手掌重重地拍在桌上,怒声道:“婚事是你爷爷当年亲自允下的,是谁让你去解除的?” 50 | “那是我的婚事,我才不要按照你们的意思嫁给谁,我的事,我自己会做主!我不管是谁允下的,我只知道,如果按照约定。嫁过去的是我,不是爷爷!”提起这事,纳兰嫣然也是脸现不愉,性子有些独立的她,很讨厌自己的大事按照别人所指定的路线行走。即使这人是她的长辈。 51 | “你别以为我不知道,你无非是认为萧炎当初一个废物配不上你是吧?可现在人家潜力不会比你低!以你在云岚宗的地位,应该早就接到过有关他实力提升的消息吧?”纳兰肃怒道。 52 | 纳兰嫣然黛眉微皱,脑海中浮现当年那充满着倔性的少年,红唇微抿,淡淡地道:“的确听说过一些关于他的消息,没想到,他竟然还真的能脱去废物的名头,这倒的确让我很意外。” 53 | “意外?一句意外就行了?你爷爷开口了。让你找个时间,再去一趟乌坦城,最好能道个歉把僵硬的关系弄缓和一些。”纳兰肃皱眉道。 54 | “道歉?不可能!” 55 | 闻言,纳兰嫣然柳眉一竖,毫不犹豫地直接拒绝,冷哼道:“他萧炎虽然不再是废物,可我纳兰嫣然依然不会嫁给他!更别提让我去道什么歉,你们喜欢,那就自己去,反正我不会再去乌坦城!” 56 | 57 | 58 | user: 59 | 参考剧情进行创作。 60 | 61 | **剧情** 62 | ``` 63 | {{context_x}} 64 | ``` 65 | 66 | assistant: 67 | 好的,这是参考剧情创作的正文。 68 | ... 69 | 70 | 71 | user: 72 | 你创作的正文大致是对的,但其中的某个片段需要修改。请根据我的意见,参考剧情片段,进行对应正文片段的创作。 73 | 74 | 75 | **意见** 76 | {{suggestion}} 77 | 78 | 79 | **剧情片段** 80 | ``` 81 | {{x}} 82 | ``` 83 | 84 | 85 | **输出格式** 86 | ``` 87 | (在这个三引号块中 根据意见 创作剧情片段对应的 正文片段) 88 | ``` 89 | -------------------------------------------------------------------------------- /prompts/根据提纲创作正文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat, log 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_last_code_block as parser 5 | 6 | 7 | def main(model, context_x, x, suggestion): 8 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 9 | 10 | prompt = template.render(context_x=context_x, x=x, suggestion=suggestion) 11 | 12 | for response_msgs in chat([], prompt, model, parse_chat=True): 13 | text = parser(response_msgs) 14 | ret = {'text': text, 'response_msgs': response_msgs} 15 | yield ret 16 | 17 | log('根据提纲创作正文', prompt, ret) 18 | 19 | return ret 20 | 21 | -------------------------------------------------------------------------------- /prompts/检索参考材料/data.yaml: -------------------------------------------------------------------------------- 1 | sample_1: 2 | model: 'ERNIE-Bot' 3 | question: 商业环境不要说空话,要说具体是什么样的环境 4 | text_chunks: 5 | - 亨利:亨利,原名亨利·沃森,是一位来自现代世界的商业大亨,拥有卓越的商业头脑和丰富的管理经验。在一次意外中,他穿越到了哈利波特的世界,却发现自己并不会魔法。在这个充满魔法与奇幻的新世界里,亨利必须依靠自己的商业智慧和人脉关系,逐步适应并融入这个全新的社会。 6 | - 在2月6日举行的外交部例行记者会上,有外媒记者提问称,美国国防部称在拉丁美洲上空发现了第二个来自中国的气球,并且美方称,在特朗普时期就有来自中国监控气球飞到美国,请问发言人如何回应? 7 | - 魔法世界的商业环境:在哈利波特的魔法世界中,商业环境与现代世界截然不同。魔法物品和魔法服务是市场的主要商品,而魔法师们则是主要的消费者群体。亨利需要深入了解这个世界的商业规则和市场需求,才能找到自己在这个新世界中的定位和发展方向。 8 | - 亨利与魔法世界的冲突与融合:亨利在魔法世界中的生活充满了挑战和冲突。他必须学会与魔法师们打交道,理解他们的思维方式和行为习惯。同时,他也要努力将自己的商业理念和方法融入到这个魔法世界中,创造出独特的商业模式和竞争优势。在这个过程中,亨利不仅逐渐适应了魔法世界的生活,也找到了自己在这个新世界中的价值和意义。 9 | - 魔法世界的商业竞争:魔法世界的商业竞争同样激烈,商家们为了争夺市场份额和客户,纷纷使出浑身解数。有的商家依靠独特的魔法物品吸引顾客,有的则提供个性化的魔法服务满足顾客需求。亨利作为来自现代世界的商业大亨,需要运用自己的商业智慧和创新思维,在魔法世界的商业竞争中脱颖而出。 10 | topk: 3 11 | 12 | sample_2: 13 | model: 'ERNIE-Bot' 14 | question: 商业环境不要说空话,要说具体是什么样的环境 15 | text_chunks: 16 | - 亨利:亨利,原名亨利·沃森,是一位来自现代世界的商业大亨 17 | - 外交部例行记者会上 18 | - 魔法世界的商业环境 19 | - 亨利与魔法世界的冲突与融合 20 | - 魔法世界的商业竞争 21 | topk: 3 22 | 23 | sample_3: 24 | model: 'ERNIE-Bot' 25 | question: 商业环境不要说空话,要说具体是什么样的环境 26 | text_chunks: 27 | - 亨利:亨利,原名亨利·沃森,是一位来自现代世界的商业大亨 28 | - 外交部例行记者会上 29 | - 魔法世界的商业环境 30 | - 亨利与魔法世界的冲突与融合 31 | - 魔法世界的商业竞争 32 | topk: 1 33 | 34 | 35 | -------------------------------------------------------------------------------- /prompts/检索参考材料/prompt.jinja2: -------------------------------------------------------------------------------- 1 | user: 2 | ###任务 3 | 我会给你参考材料和问题,需要你选出对于给定问题最具有参考价值的参考材料。 4 | 5 | ###参考材料 6 | {% for chunk in references %} 7 | ({{ loop.index }}){{ chunk }} 8 | {% endfor %} 9 | 10 | ###问题 11 | {{question}} 12 | 13 | ###输出格式 14 | //以JSON格式输出 15 | { 16 | "TOP-{{topk}}": [?, ?, ...] //在列表中按重要性从高到低依次填写{{topk}}个参考材料的序号,填序号即可。 17 | } 18 | -------------------------------------------------------------------------------- /prompts/检索参考材料/prompt.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from prompts.chat_utils import chat 4 | from prompts.prompt_utils import load_jinja2_template, match_first_json_block 5 | 6 | 7 | def parser(response_msgs, text_chunks, topk): 8 | content = response_msgs[-1]['content'] 9 | 10 | try: 11 | content = match_first_json_block(content) 12 | content_json = json.loads(content) 13 | if content_json and isinstance(topk_indexes := next(iter(content_json.values())), list): 14 | topk_indexes = [int(e) - 1 for e in topk_indexes[:topk]] 15 | if all(0 <= e < len(text_chunks) for e in topk_indexes): 16 | return topk_indexes[:topk] 17 | except Exception as e: 18 | import traceback 19 | traceback.print_exc() 20 | 21 | return None 22 | 23 | 24 | def main(model, question, text_chunks, topk): 25 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 26 | 27 | prompt = template.render(references=text_chunks, 28 | question=question, 29 | topk=topk) 30 | 31 | for response_msgs in chat([], prompt, model, max_tokens=10 + topk * 4, response_json=True, parse_chat=True): 32 | try: 33 | match_first_json_block(response_msgs[-1]['content']) 34 | except Exception: 35 | pass 36 | else: 37 | topk_indexes = parser(response_msgs, text_chunks, topk) 38 | return {'topk_indexes': topk_indexes, 'response_msgs':response_msgs} 39 | 40 | yield response_msgs 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /prompts/生成创作正文的上下文/flow.dag.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json 2 | environment: 3 | python_requirements_txt: requirements.txt 4 | inputs: 5 | chat_messages: 6 | type: list 7 | default: [] 8 | model: 9 | type: string 10 | default: ERNIE-Bot 11 | config: 12 | type: object 13 | default: 14 | auto_compress_context: true 15 | text: 16 | type: string 17 | context: 18 | type: string 19 | outputs: 20 | knowledge: 21 | type: string 22 | reference: ${parser.output} 23 | nodes: 24 | - name: prompt 25 | type: prompt 26 | source: 27 | type: code 28 | path: prompt.jinja2 29 | inputs: 30 | text: ${inputs.text} 31 | context: ${inputs.context} 32 | - name: chat 33 | type: python 34 | source: 35 | type: code 36 | path: ../tool_chat.py 37 | inputs: 38 | messages: ${inputs.chat_messages} 39 | prompt: ${prompt.output} 40 | model: ${inputs.model} 41 | response_json: false 42 | parse_chat: true 43 | aggregation: false 44 | - name: parser 45 | type: python 46 | source: 47 | type: code 48 | path: ../tool_parser.py 49 | inputs: 50 | response_type: content 51 | response_msgs: ${chat.output} 52 | -------------------------------------------------------------------------------- /prompts/生成创作正文的上下文/prompt.jinja2: -------------------------------------------------------------------------------- 1 | {# 该Prompt用于向章节Agent提问来获取上下文,输入:正文 输出:上下文 #} 2 | user: 3 | **任务** 4 | 你现在是一个小说家,正在创作正文,你需要从下面上下文中提取并概括出对创作正文最有帮助的信息。 5 | 6 | **上下文** 7 | {{context}} 8 | 9 | **正文片段** 10 | {{text}} 11 | 12 | **输出格式** 13 | ### 提取信息 14 | (这里从上下文中提取出与正文最相关的信息,用简洁精确的语言描述) -------------------------------------------------------------------------------- /prompts/生成创作正文的上下文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_content as parser 5 | 6 | 7 | def main(model, text, context): 8 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 9 | 10 | prompt = template.render(text=text, 11 | context=context) 12 | 13 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 14 | 15 | knowledge = parser(response_msgs) 16 | 17 | return {'knowledge': knowledge, 'response_msgs':response_msgs} 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /prompts/生成创作正文的意见/flow.dag.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json 2 | environment: 3 | python_requirements_txt: requirements.txt 4 | inputs: 5 | chat_messages: 6 | type: list 7 | default: [] 8 | model: 9 | type: string 10 | default: ERNIE-Bot-4 11 | config: 12 | type: object 13 | default: 14 | auto_compress_context: true 15 | instruction: 16 | type: string 17 | context: 18 | type: string 19 | text: 20 | type: string 21 | default: "" 22 | selected_text: 23 | type: string 24 | default: "" 25 | outputs: 26 | suggestion: 27 | type: string 28 | reference: ${parser.output} 29 | nodes: 30 | - name: prompt 31 | type: prompt 32 | source: 33 | type: code 34 | path: prompt.jinja2 35 | inputs: 36 | context: ${inputs.context} 37 | instruction: ${inputs.instruction} 38 | text: ${inputs.text} 39 | selected_text: ${inputs.selected_text} 40 | - name: chat 41 | type: python 42 | source: 43 | type: code 44 | path: ../tool_chat.py 45 | inputs: 46 | messages: ${inputs.chat_messages} 47 | prompt: ${prompt.output} 48 | model: ${inputs.model} 49 | response_json: false 50 | parse_chat: true 51 | aggregation: false 52 | - name: parser 53 | type: python 54 | source: 55 | type: code 56 | path: parser.py 57 | inputs: 58 | response_msgs: ${chat.output} 59 | -------------------------------------------------------------------------------- /prompts/生成创作正文的意见/parser.py: -------------------------------------------------------------------------------- 1 | from promptflow.core import tool 2 | 3 | 4 | @tool 5 | def parse_response(response_msgs): 6 | from prompts.prompt_utils import parse_chunks_by_separators 7 | content = response_msgs[-1]['content'] 8 | 9 | chunks = parse_chunks_by_separators(content, [r'\S*', ]) 10 | if "意见" in chunks: 11 | return chunks["意见"] 12 | else: 13 | return content 14 | -------------------------------------------------------------------------------- /prompts/生成创作正文的意见/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个网文编辑,正在指导写手进行正文的创作。 4 | 你需要针对写手提出的要求进行分析,给出具体的创作正文的意见。 5 | 6 | **输入** 7 | 要求 8 | 上下文 9 | 10 | user: 11 | **上下文** 12 | {{context}} 13 | 14 | **正文** 15 | {{text}} 16 | 17 | assistant: 18 | 收到了你发来的正文和上下文,还需要说明你的要求。 19 | 20 | user: 21 | **要求** 22 | {{instruction}} 23 | 24 | {% if selected_text %} 25 | **引用正文片段** 26 | {{selected_text}} 27 | {% endif %} 28 | 29 | **输出格式** 30 | ###思考 31 | (直击核心,简明扼要地揭示问题关键) 32 | 33 | ###意见 34 | (精准、犀利地给出你的主要观点或建议,力求一语中的) 35 | -------------------------------------------------------------------------------- /prompts/生成创作正文的意见/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_named_chunk 5 | 6 | 7 | def parser(response_msgs): 8 | return parse_named_chunk(response_msgs, '意见') 9 | 10 | 11 | def main(model, instruction, text, context, selected_text=None): 12 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 13 | 14 | prompt = template.render(instruction=instruction, 15 | text=text, 16 | context=context, 17 | selected_text=selected_text) 18 | 19 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 20 | 21 | suggestion = parser(response_msgs) 22 | 23 | return {'suggestion': suggestion, 'response_msgs':response_msgs} 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /prompts/生成创作章节的上下文/flow.dag.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json 2 | environment: 3 | python_requirements_txt: requirements.txt 4 | inputs: 5 | chat_messages: 6 | type: list 7 | default: [] 8 | model: 9 | type: string 10 | default: ERNIE-Bot 11 | config: 12 | type: object 13 | default: 14 | auto_compress_context: true 15 | text: 16 | type: string 17 | context: 18 | type: string 19 | outputs: 20 | knowledge: 21 | type: string 22 | reference: ${parser.output} 23 | nodes: 24 | - name: prompt 25 | type: prompt 26 | source: 27 | type: code 28 | path: prompt.jinja2 29 | inputs: 30 | text: ${inputs.text} 31 | context: ${inputs.context} 32 | - name: chat 33 | type: python 34 | source: 35 | type: code 36 | path: ../tool_chat.py 37 | inputs: 38 | messages: ${inputs.chat_messages} 39 | prompt: ${prompt.output} 40 | model: ${inputs.model} 41 | response_json: false 42 | parse_chat: true 43 | aggregation: false 44 | - name: parser 45 | type: python 46 | source: 47 | type: code 48 | path: ../tool_parser.py 49 | inputs: 50 | response_type: content 51 | response_msgs: ${chat.output} 52 | -------------------------------------------------------------------------------- /prompts/生成创作章节的上下文/prompt.jinja2: -------------------------------------------------------------------------------- 1 | {# 该Prompt用于向大纲Agent提问来获取上下文,输入:章节剧情 输出:上下文 #} 2 | user: 3 | **任务** 4 | 你现在是一个小说家,正在创作章节剧情,你需要从下面上下文中提取并概括出对创作章节剧情最有帮助的信息。 5 | 6 | **上下文** 7 | {{context}} 8 | 9 | **章节剧情片段** 10 | {{text}} 11 | 12 | **输出格式** 13 | ### 提取信息 14 | (这里从上下文中提取出与章节剧情最相关的信息,用简洁精确的语言描述) -------------------------------------------------------------------------------- /prompts/生成创作章节的上下文/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_content as parser 5 | 6 | 7 | def main(model, text, context): 8 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 9 | 10 | prompt = template.render(text=text, 11 | context=context) 12 | 13 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 14 | 15 | knowledge = parser(response_msgs) 16 | 17 | return {'knowledge': knowledge, 'response_msgs':response_msgs} 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /prompts/生成创作章节的意见/flow.dag.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json 2 | environment: 3 | python_requirements_txt: requirements.txt 4 | inputs: 5 | chat_messages: 6 | type: list 7 | default: [] 8 | model: 9 | type: string 10 | default: ERNIE-Bot-4 11 | config: 12 | type: object 13 | default: 14 | auto_compress_context: true 15 | instruction: 16 | type: string 17 | context: 18 | type: string 19 | outputs: 20 | suggestion: 21 | type: string 22 | reference: ${parser.output} 23 | nodes: 24 | - name: prompt 25 | type: prompt 26 | source: 27 | type: code 28 | path: prompt.jinja2 29 | inputs: 30 | context: ${inputs.context} 31 | instruction: ${inputs.instruction} 32 | - name: chat 33 | type: python 34 | source: 35 | type: code 36 | path: ../tool_chat.py 37 | inputs: 38 | messages: ${inputs.chat_messages} 39 | prompt: ${prompt.output} 40 | model: ${inputs.model} 41 | response_json: false 42 | parse_chat: true 43 | aggregation: false 44 | - name: parser 45 | type: python 46 | source: 47 | type: code 48 | path: parser.py 49 | inputs: 50 | response_msgs: ${chat.output} 51 | -------------------------------------------------------------------------------- /prompts/生成创作章节的意见/parser.py: -------------------------------------------------------------------------------- 1 | from promptflow.core import tool 2 | 3 | 4 | @tool 5 | def parse_response(response_msgs): 6 | from prompts.prompt_utils import parse_chunks_by_separators 7 | content = response_msgs[-1]['content'] 8 | 9 | chunks = parse_chunks_by_separators(content, [r'\S*', ]) 10 | if "意见" in chunks: 11 | return chunks["意见"] 12 | else: 13 | return content 14 | -------------------------------------------------------------------------------- /prompts/生成创作章节的意见/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个网文编辑,正在指导写手进行章节剧情的创作。 4 | 你需要针对写手提出的要求进行分析,给出具体的创作章节剧情的意见。 5 | 6 | **输入** 7 | 要求 8 | 上下文 9 | 10 | user: 11 | **要求** 12 | {{instruction}} 13 | 14 | assistant: 15 | 明白你的要求,还要给出上下文。 16 | 17 | user: 18 | **要求** 19 | {{instruction}} 20 | 21 | **上下文** 22 | {{context}} 23 | 24 | **输出格式** 25 | ###思考 26 | (直击核心,简明扼要地揭示问题关键) 27 | 28 | ###意见 29 | (精准、犀利地给出你的主要观点或建议,力求一语中的) 30 | -------------------------------------------------------------------------------- /prompts/生成创作章节的意见/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_named_chunk 5 | 6 | 7 | def parser(response_msgs): 8 | return parse_named_chunk(response_msgs, '意见') 9 | 10 | 11 | def main(model, instruction=None, context=None): 12 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 13 | 14 | prompt = template.render(instruction=instruction, 15 | context=context) 16 | 17 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 18 | 19 | suggestion = parser(response_msgs) 20 | 21 | return {'suggestion': suggestion, 'response_msgs':response_msgs} 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /prompts/生成创作设定的意见/data.yaml: -------------------------------------------------------------------------------- 1 | xsample_1: 2 | model: 'ERNIE-Bot' 3 | instruction: 完善主角和世界相关设定。 4 | context: 小说名为《商业大亨穿越到哈利波特世界,但我不会魔法》。 5 | chunks: 6 | 亨利: 主角名为亨利,是商业大亨,但是不会魔法。 7 | 8 | sample_2: 9 | model: 'ERNIE-Bot' 10 | instruction: 商业环境不要说空话,要说具体是什么样的环境 11 | chunks: 12 | 亨利: 亨利,原名亨利·沃森,是一位来自现代世界的商业大亨,拥有卓越的商业头脑和丰富的管理经验。在一次意外中,他穿越到了哈利波特的世界,却发现自己并不会魔法。在这个充满魔法与奇幻的新世界里,亨利必须依靠自己的商业智慧和人脉关系,逐步适应并融入这个全新的社会。 13 | 魔法世界的商业环境: 在哈利波特的魔法世界中,商业环境与现代世界截然不同。魔法物品和魔法服务是市场的主要商品,而魔法师们则是主要的消费者群体。亨利需要深入了解这个世界的商业规则和市场需求,才能找到自己在这个新世界中的定位和发展方向。 14 | 亨利与魔法世界的冲突与融合: 亨利在魔法世界中的生活充满了挑战和冲突。他必须学会与魔法师们打交道,理解他们的思维方式和行为习惯。同时,他也要努力将自己的商业理念和方法融入到这个魔法世界中,创造出独特的商业模式和竞争优势。在这个过程中,亨利不仅逐渐适应了魔法世界的生活,也找到了自己在这个新世界中的价值和意义。 15 | sample_3: 16 | model: 'ERNIE-Bot-4' 17 | instruction: 商业环境不要说空话,要说具体是什么样的环境 18 | chunks: 19 | 亨利: 亨利,原名亨利·沃森,是一位来自现代世界的商业大亨,拥有卓越的商业头脑和丰富的管理经验。在一次意外中,他穿越到了哈利波特的世界,却发现自己并不会魔法。在这个充满魔法与奇幻的新世界里,亨利必须依靠自己的商业智慧和人脉关系,逐步适应并融入这个全新的社会。 20 | 魔法世界的商业环境: 在哈利波特的魔法世界中,商业环境与现代世界截然不同。魔法物品和魔法服务是市场的主要商品,而魔法师们则是主要的消费者群体。亨利需要深入了解这个世界的商业规则和市场需求,才能找到自己在这个新世界中的定位和发展方向。 21 | 亨利与魔法世界的冲突与融合: 亨利在魔法世界中的生活充满了挑战和冲突。他必须学会与魔法师们打交道,理解他们的思维方式和行为习惯。同时,他也要努力将自己的商业理念和方法融入到这个魔法世界中,创造出独特的商业模式和竞争优势。在这个过程中,亨利不仅逐渐适应了魔法世界的生活,也找到了自己在这个新世界中的价值和意义。 -------------------------------------------------------------------------------- /prompts/生成创作设定的意见/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个网文编辑,正在指导写手进行网文设定的创作。 4 | 你需要针对写手提出的要求进行分析,给出具体的创作网文设定的意见。 5 | 6 | **输入** 7 | 要求 8 | 上下文 9 | 原设定 10 | 11 | {% if context %} 12 | user: 13 | **上下文** 14 | {{context}} 15 | 16 | assistant: 17 | 收到了上下文,你还需要给我其他相关信息。 18 | {% endif %} 19 | 20 | {% if chunks %} 21 | user: 22 | **原设定** 23 | ``` 24 | {{chunks}} 25 | ``` 26 | 27 | assistant: 28 | 收到了原设定,你还需要给我其他相关信息。 29 | {% endif %} 30 | 31 | user: 32 | **要求** 33 | {% if instruction %} 34 | {{instruction}} 35 | {% else %} 36 | 创作设定。 37 | {% endif %} 38 | 39 | **输出格式** 40 | ###思考 41 | (直击核心,简明扼要地揭示问题关键) 42 | 43 | ###意见 44 | (精准、犀利地给出你的主要观点或建议,力求一语中的) 45 | 46 | -------------------------------------------------------------------------------- /prompts/生成创作设定的意见/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_named_chunk 5 | 6 | 7 | def parser(response_msgs): 8 | return parse_named_chunk(response_msgs, '意见') 9 | 10 | 11 | def main(model, instruction=None, chunks=None, context=None): 12 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 13 | 14 | prompt = template.render(instruction=instruction, 15 | chunks="\n\n".join([f"###{k}\n{v}" for k, v in chunks.items()]) if chunks else None, 16 | context=context) 17 | 18 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 19 | 20 | suggestion = parser(response_msgs) 21 | 22 | return {'suggestion': suggestion, 'response_msgs':response_msgs} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /prompts/生成重写正文的意见/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个网文作家,正在构思正文,你需要在给定提纲的基础上进行创作,并积极响应用户反馈给出修改意见。 4 | 5 | 6 | user: 7 | **提纲** 8 | ``` 9 | {{chapter}} 10 | ``` 11 | 12 | **输出格式** 13 | ``` 14 | (在这个三引号块中输出正文) 15 | ``` 16 | 17 | assistant: 18 | 好的,这是根据提纲创作的正文。 19 | ``` 20 | {{text}} 21 | ``` 22 | 23 | user: 24 | 我对于你刚刚创作的正文中的某个片段不满意,我需要你给出修改意见。 25 | 给出修改意见时,请注意以下几点: 26 | 1. 直击核心,简明扼要地揭示问题关键 27 | 2. 不要泛泛而谈,具体到对什么剧情/人物/情节/细节的修改意见 28 | 3. 不要给出大段文字,尽量控制在100字以内 29 | 30 | 31 | **正文片段** 32 | ``` 33 | {{selected_text}} 34 | ``` 35 | 36 | 37 | **输出格式** 38 | ``` 39 | (在这个三引号块中输出对该正文片段的修改意见) 40 | ``` -------------------------------------------------------------------------------- /prompts/生成重写正文的意见/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_last_code_block as parser 5 | 6 | 7 | # 生成重写正文的意见 8 | def main(model, chapter, text, selected_text): 9 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 10 | 11 | prompt = template.render(chapter=chapter, 12 | text=text, 13 | selected_text=selected_text) 14 | 15 | for response_msgs in chat([], prompt, model, parse_chat=True): 16 | suggestion = parser(response_msgs) 17 | ret = {'suggestion': suggestion, 'response_msgs':response_msgs} 18 | yield ret 19 | 20 | return ret 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /prompts/生成重写章节的意见/flow.dag.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json 2 | environment: 3 | python_requirements_txt: requirements.txt 4 | inputs: 5 | chat_messages: 6 | type: list 7 | default: [] 8 | model: 9 | type: string 10 | default: ERNIE-Bot-4 11 | config: 12 | type: object 13 | default: 14 | auto_compress_context: true 15 | text: 16 | type: string 17 | instruction: 18 | type: string 19 | context: 20 | type: string 21 | outputs: 22 | suggestion: 23 | type: string 24 | reference: ${parser.output} 25 | nodes: 26 | - name: prompt 27 | type: prompt 28 | source: 29 | type: code 30 | path: prompt.jinja2 31 | inputs: 32 | text: ${inputs.text} 33 | context: ${inputs.context} 34 | instruction: ${inputs.instruction} 35 | - name: chat 36 | type: python 37 | source: 38 | type: code 39 | path: ../tool_chat.py 40 | inputs: 41 | messages: ${inputs.chat_messages} 42 | prompt: ${prompt.output} 43 | model: ${inputs.model} 44 | response_json: false 45 | parse_chat: true 46 | aggregation: false 47 | - name: parser 48 | type: python 49 | source: 50 | type: code 51 | path: parser.py 52 | inputs: 53 | response_msgs: ${chat.output} 54 | -------------------------------------------------------------------------------- /prompts/生成重写章节的意见/parser.py: -------------------------------------------------------------------------------- 1 | from promptflow.core import tool 2 | 3 | 4 | @tool 5 | def parse_response(response_msgs): 6 | from prompts.prompt_utils import parse_chunks_by_separators 7 | content = response_msgs[-1]['content'] 8 | 9 | chunks = parse_chunks_by_separators(content, [r'\S*', ]) 10 | if "改进意见" in chunks: 11 | return chunks["改进意见"] 12 | else: 13 | raise Exception(f"无法解析回复,找不到改进意见!") 14 | -------------------------------------------------------------------------------- /prompts/生成重写章节的意见/prompt.jinja2: -------------------------------------------------------------------------------- 1 | system: 2 | **任务** 3 | 你是一个网文编辑,正在审阅写手发来的剧情,并针对写手提出的要求进行分析,给出具体的改进意见。 4 | 5 | **输入** 6 | 要求 7 | 原剧情 8 | 上下文 9 | 10 | **输出格式** 11 | ### 对原剧情哪些描述进行改进 12 | (这里结合改进建议,进行详细的分点的有条理的思考) 13 | 14 | ### 改进意见 15 | (这里分点给出具体的有针对性的改进意见) 16 | 17 | **改进准则** 18 | 1. 不能增加/引入/加入原剧情中没有的事件/概念 19 | 2. 不能在原剧情结尾进行引申或总结 20 | 3. 必须在原剧情中已有的描述上进行改进 21 | 22 | user: 23 | **要求** 24 | {{instruction}} 25 | 26 | assistant: 27 | 明白你的要求,还要给出原剧情和上下文。 28 | 29 | user: 30 | **要求** 31 | {{instruction}} 32 | 33 | **原剧情** 34 | {{text}} 35 | 36 | **上下文** 37 | {{context}} 38 | 39 | **改进准则** 40 | 1. 不能增加/引入/加入原剧情中没有的事件/概念 41 | 2. 不能在原剧情结尾进行引申或总结 42 | 3. 必须在原剧情中已有的描述上进行改进 43 | {#情节设计时不要苦大仇深,也不要奋发激昂,尽量幽默风趣点。#} 44 | 45 | **输出格式** 46 | ### 对原剧情哪些已有描述进行改进 47 | (这里结合要求,进行详细的分点的有条理的思考,每个点都要符合改进准则) 48 | 49 | ### 改进意见 50 | (这里分点给出具体的有针对性的改进意见) -------------------------------------------------------------------------------- /prompts/生成重写章节的意见/prompt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from prompts.chat_utils import chat 3 | from prompts.prompt_utils import load_jinja2_template 4 | from prompts.common_parser import parse_named_chunk 5 | 6 | 7 | def parser(response_msgs): 8 | return parse_named_chunk(response_msgs, '意见') 9 | 10 | 11 | def main(model, instruction, text, context): 12 | template = load_jinja2_template(os.path.join(os.path.dirname(os.path.join(__file__)), "prompt.jinja2")) 13 | 14 | prompt = template.render(instruction=instruction, 15 | text=text, 16 | context=context) 17 | 18 | response_msgs = yield from chat([], prompt, model, parse_chat=True) 19 | 20 | suggestion = parser(response_msgs) 21 | 22 | return {'suggestion': suggestion, 'response_msgs':response_msgs} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

Long-Novel-GPT

2 | 3 |

4 | AI一键生成长篇小说 5 |

6 | 7 |

8 | 关于项目 • 9 | 更新日志 • 10 | 小说生成Prompt • 11 | 快速上手 • 12 | Demo使用指南 13 |

14 | 15 |
16 | 17 |

🎯 关于项目

18 | 19 | Long-Novel-GPT的核心是一个基于LLM和RAG的长篇小说Agent,根据用户的提问(要写什么小说,要对什么情节做出什么改动)LNGPT会调用工具检索相关正文片段和剧情纲要,并且对相关正文片段进行修改,同时更新剧情纲要。流程如下: 20 | 21 |

22 | Long Novel Agent Architecture 23 |

24 | 25 | 1. 从本地导入现有小说 26 | 2. 拆书(提取剧情人物关系,生成剧情纲要) 27 | 3. 输入你的意见 28 | 4. 检索相关正文片段和剧情纲要 29 | 5. 对正文片段进行修改 30 | 6. 同步更新剧情纲要 31 | 32 | 33 |

📅 更新日志

34 | 35 | ### 🎉 Long-Novel-GPT 2.2 更新 36 | - 支持查看Prompt 37 | - **支持导入小说,在已有的小说基础上进行改写** 38 | - 支持在**设置**中选择模型 39 | - 支持在创作时实时**显示调用费用** 40 | 41 |

42 | 支持在已有的小说基础上进行改写 43 |

44 | 45 | ### 🎉 Long-Novel-GPT 2.1 更新 46 | - 支持选择和创作章节 47 | 48 | ### 🎉 Long-Novel-GPT 2.0 更新 49 | - 提供全新的UI界面 50 | 51 | 52 | ### 🔮 后续更新计划 53 | - 考虑一个更美观更实用的编辑界面(已完成) 54 | - 支持文心 Novel 模型(已完成) 55 | - 支持豆包模型(已完成) 56 | - 通过一个创意直接一键生成完整长篇小说(进行中) 57 | - 支持生成大纲和章节(进行中) 58 | 59 | 60 |

📚 小说生成 Prompt

61 | 62 | | Prompt | 描述 | 63 | |--------|------| 64 | | [天蚕土豆风格](custom/根据提纲创作正文/天蚕土豆风格.txt) | 用于根据提纲创作正文,模仿天蚕土豆的写作风格 | 65 | | [对草稿进行润色](custom/根据提纲创作正文/对草稿进行润色.txt) | 对你写的网文初稿进行润色和改进 | 66 | 67 | [📝 提交你的 Prompt](https://github.com/MaoXiaoYuZ/Long-Novel-GPT/issues/new?assignees=&labels=prompt&template=custom_prompt.md&title=新的Prompt) 68 | 69 |

🚀 快速上手

70 | 71 | ### Docker一键部署 72 | 73 | 运行下面命令拉取long-novel-gpt镜像 74 | ```bash 75 | docker pull maoxiaoyuz/long-novel-gpt:latest 76 | ``` 77 | 78 | 下载或复制[.env.example](.env.example)文件,将其放在你的任意一个目录下,将其改名为 **.env**, 并根据文件中提示填写API设置。 79 | 80 | 填写完成后在该 **.env**文件目录下,运行以下命令: 81 | ```bash 82 | docker run -p 80:80 --env-file .env -d maoxiaoyuz/long-novel-gpt:latest 83 | ``` 84 | **注意,如果你在启动后改动了.env文件,那么必须关闭已启动的容器后,再运行上述命令才行。** 85 | 86 | 接下来访问 http://localhost 即可使用,如果是部署在服务器上,则访问你的服务器公网地址即可。 87 | 88 | 89 |

90 | Gradio DEMO有5个Tab页面 91 |

92 | 93 | ### 使用本地的大模型服务 94 | 要使用本地的大模型服务,只需要在Docker部署时额外注意以下两点。 95 | 96 | 第一,启动Docker的命令需要添加额外参数,具体如下: 97 | ```bash 98 | docker run -p 80:80 --env-file .env -d --add-host=host.docker.internal:host-gateway maoxiaoyuz/long-novel-gpt:latest 99 | ``` 100 | 101 | 第二,将本地的大模型服务暴露为OpenAI格式接口,在[.env.example](.env.example)文件中进行配置,同时GPT_BASE_URL中localhost或127.0.0.1需要替换为:**host.docker.internal** 102 | 例如 103 | ``` 104 | # 这里GPT_BASE_URL格式只提供参考,主要是替换localhost或127.0.0.1 105 | # 可用的模型名可以填1个或多个,用英文逗号分隔 106 | LOCAL_BASE_URL=http://host.docker.internal:7777/v1 107 | LOCAL_API_KEY=you_api_key 108 | LOCAL_AVAILABLE_MODELS=model_name1,model_name2 109 | # 只有一个模型就只写一个模型名,多个模型要用英文逗号分割 110 | ``` 111 | 112 |

🖥️ Demo 使用指南

113 | 114 | ### 当前Demo能生成百万字小说吗? 115 | Long-Novel-GPT-2.1版本完全支持生成百万级别小说的版本,而且是多窗口同步生成,速度非常快。 116 | 117 | 同时你可以自由控制你需要生成的部分,对选中部分重新生成等等。 118 | 119 | 而且,Long-Novel-GPT-2.1会自动管理上下文,在控制API调用费用的同时确保了生成剧情的连续。 120 | 121 | 在2.1版本中,你需要部署在本地并采用自己的API-Key,在[.env.example](.env.example)文件中配置生成时采用的最大线程数。 122 | ``` 123 | # Thread Configuration - 线程配置 124 | # 生成时采用的最大线程数 125 | MAX_THREAD_NUM=5 126 | ``` 127 | 在线Demo是不行的,因为最大线程为5。 128 | 129 | ### 如何利用LN-GPT-2.1生成百万字小说? 130 | 首先,你需要部署在本地,配置API-Key并解除线程限制。 131 | 132 | 然后,在**创作章节**阶段,创作50章,每章200字。(50+线程并行) 133 | 134 | 其次,在**创作剧情**阶段,将每章的200字扩充到1k字。 135 | 136 | 最后,在**创作正文**阶段,将每章的1K字扩充到2k字,这一步主要是润色文本和描写。 137 | 138 | 一共,50 * 2k = 100k (十万字)。 139 | 140 | **创作章节支持创作无限长度的章节数,同理,剧情和正文均不限长度,LNGPT会自动进行切分,自动加入上下文,并自动采取多个线程同时创作。** 141 | 142 | ### LN-GPT-2.1生成的百万字小说怎么样? 143 | 总的来说,2.1版本能够实现在用户监督下生成达到签约门槛的网文。 144 | 145 | 而且,我们的最终目标始终是实现一键生成全书,将在2-3个版本迭代后正式推出。 146 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 设置默认值 4 | FRONTEND_PORT=${FRONTEND_PORT:-80} 5 | BACKEND_PORT=${BACKEND_PORT:-7869} 6 | BACKEND_HOST=${BACKEND_HOST:-0.0.0.0} 7 | WORKERS=${WORKERS:-4} 8 | THREADS=${THREADS:-2} 9 | TIMEOUT=${TIMEOUT:-120} 10 | 11 | # 在Linux环境下添加host.docker.internal解析 12 | # if ! grep -q "host.docker.internal" /etc/hosts; then 13 | # DOCKER_INTERNAL_HOST="$(ip route | grep default | awk '{print $3}')" 14 | # echo "$DOCKER_INTERNAL_HOST host.docker.internal" >> /etc/hosts 15 | # fi 16 | 17 | # 替换nginx配置中的端口 18 | sed -i "s/listen 9999/listen $FRONTEND_PORT/g" /etc/nginx/conf.d/default.conf 19 | sed -i "s/host.docker.internal:7869/localhost:$BACKEND_PORT/g" /etc/nginx/conf.d/default.conf 20 | 21 | # 启动nginx 22 | nginx 23 | 24 | # 启动gunicorn 25 | gunicorn --bind $BACKEND_HOST:$BACKEND_PORT \ 26 | --workers $WORKERS \ 27 | --threads $THREADS \ 28 | --worker-class gthread \ 29 | --timeout $TIMEOUT \ 30 | --access-logfile - \ 31 | --error-logfile - \ 32 | app:app -------------------------------------------------------------------------------- /tests/test_summary.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # Add the project root to the Python path 5 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 6 | 7 | from core.parser_utils import parse_chapters 8 | from core.summary_novel import summary_draft, summary_plot, summary_chapters 9 | from prompts.prompt_utils import load_text 10 | from llm_api import ModelConfig 11 | from rich.live import Live 12 | from rich.table import Table 13 | from rich import box 14 | from rich.console import Console 15 | 16 | 17 | def batch_yield(generators, max_co_num=5, ret=[]): 18 | results = [None] * len(generators) 19 | yields = [None] * len(generators) 20 | finished = [False] * len(generators) 21 | 22 | while True: 23 | co_num = 0 24 | for i, gen in enumerate(generators): 25 | if finished[i]: 26 | continue 27 | 28 | try: 29 | co_num += 1 30 | yield_value = next(gen) 31 | yields[i] = yield_value 32 | except StopIteration as e: 33 | results[i] = e.value 34 | finished[i] = True 35 | 36 | if co_num >= max_co_num: 37 | break 38 | 39 | if all(finished): 40 | break 41 | 42 | yield yields 43 | 44 | ret.clear() 45 | ret.extend(results) 46 | return ret 47 | 48 | def create_progress_table(yields): 49 | table = Table(box=box.MINIMAL) 50 | table.add_column("Progress") 51 | for item in yields: 52 | if item is not None: 53 | table.add_row(item) 54 | return table 55 | 56 | # 创建一个控制台对象 57 | console = Console() 58 | 59 | model = ModelConfig(model='glm-4-plus', api_key='68225a7a158bd3674bf07edbd248d620.15paBYrpUn0o8Dvi', max_tokens=4000) 60 | sub_model = ModelConfig(model='glm-4-flashx', api_key='68225a7a158bd3674bf07edbd248d620.15paBYrpUn0o8Dvi', max_tokens=4000) 61 | 62 | novel_text = load_text("data/斗破苍穹.txt", 100_000) 63 | 64 | chapter_titles, chapter_contents = parse_chapters(novel_text) 65 | 66 | 67 | with Live(refresh_per_second=4) as live: 68 | dw_list = [] 69 | gens = [summary_draft(model, sub_model, ' '.join(title), content) for title, content in zip(chapter_titles, chapter_contents)] 70 | for yields in batch_yield(gens, ret=dw_list): 71 | table = create_progress_table([y for y in yields if y is not None]) 72 | live.update(table) 73 | 74 | cw_list = [] 75 | gens = [summary_plot(model, sub_model, ' '.join(title), dw.x) for title, dw in zip(chapter_titles, dw_list)] 76 | for yields in batch_yield(gens, ret=cw_list): 77 | table = create_progress_table([y for y in yields if y is not None]) 78 | live.update(table) 79 | 80 | 81 | ow_list = [] 82 | gens = [summary_chapters(model, sub_model, '斗破', chapter_titles, [cw.global_context['chapter'] for cw in cw_list])] 83 | for yields in batch_yield(gens, ret=ow_list): 84 | table = create_progress_table([y for y in yields if y is not None]) 85 | live.update(table) 86 | -------------------------------------------------------------------------------- /tests/test_writer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import yaml 4 | import gradio as gr 5 | from itertools import chain 6 | import pickle 7 | 8 | # Add the project root to the Python path 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from core.writer import Writer 12 | from llm_api import ModelConfig 13 | 14 | ak, sk = '', '' 15 | model = ModelConfig(model='ERNIE-4.0-8K', ak=ak, sk=sk, max_tokens=4000) 16 | sub_model = ModelConfig(model='ERNIE-3.5-8K', ak=ak, sk=sk, max_tokens=4000) 17 | 18 | writer = None 19 | 20 | PICKLE_FILE = 'writer_state.pkl' 21 | 22 | def save_writer(): 23 | global writer 24 | if writer is not None: 25 | with open(PICKLE_FILE, 'wb') as f: 26 | pickle.dump(writer, f) 27 | return "Writer saved to pickle file" 28 | else: 29 | return "No Writer instance to save" 30 | 31 | 32 | def load_writer(): 33 | global writer 34 | if os.path.exists(PICKLE_FILE): 35 | with open(PICKLE_FILE, 'rb') as f: 36 | writer = pickle.load(f) 37 | return "Writer loaded from pickle file" 38 | else: 39 | return "No Writer instance to load" 40 | 41 | load_writer() 42 | writer.batch_map(prompt="", y_span=(0, len(writer.y)), chunk_length=1000, context_length=0, smooth=True) 43 | pass 44 | 45 | def initialize_writer(plot, text): 46 | global writer 47 | if os.path.exists(PICKLE_FILE): 48 | load_writer() 49 | return "Writer loaded from pickle file" 50 | else: 51 | writer = Writer(x=plot, y=text, model=model, sub_model=sub_model) 52 | return "New Writer initialized" 53 | 54 | def test_update_map(plot, text): 55 | global writer 56 | if writer is None: 57 | return "Please initialize the writer first." 58 | 59 | writer.x = plot 60 | writer.y = text 61 | 62 | # Execute update_map 63 | for output in writer.update_map(): 64 | yield output['response_msgs'].response 65 | 66 | # Generate output string 67 | output = "" 68 | dash_count = 20 69 | output += f"\n{'-' * dash_count}xy_pairs{'-' * dash_count}\n" 70 | for i, (plot_chunk, text_chunk) in enumerate(writer.xy_pairs, 1): 71 | output += f"{'-' * dash_count}pair {i}{'-' * dash_count}\n" 72 | output += f"x: {plot_chunk}\n" 73 | output += f"y: {text_chunk}\n" 74 | output += f"{'-' * (dash_count * 2 + 8)}\n" 75 | 76 | yield output 77 | 78 | return output 79 | 80 | def run_gradio_interface(): 81 | # Load examples from YAML file 82 | with open('prompts/text-plot-examples.yaml', 'r', encoding='utf-8') as file: 83 | examples_data = yaml.safe_load(file) 84 | 85 | # Get the first example 86 | first_example = examples_data['examples'][0] 87 | plot = first_example['plot'] 88 | text = first_example['text'] 89 | 90 | # Define Gradio blocks 91 | with gr.Blocks(title="Writer Test Interface") as demo: 92 | gr.Markdown("# Writer Test Interface") 93 | gr.Markdown("Test the Writer's update_map function with the first example.") 94 | 95 | with gr.Row(): 96 | plot_input = gr.Textbox(label="X (Plot)", value=plot, lines=20) 97 | text_input = gr.Textbox(label="Y (Text)", value=text, lines=20) 98 | 99 | with gr.Row(): 100 | init_button = gr.Button("Initialize Writer") 101 | test_button = gr.Button("Test update_map") 102 | save_button = gr.Button("Save Writer") 103 | 104 | output = gr.Textbox(label="Output") 105 | 106 | init_button.click(fn=initialize_writer, inputs=[plot_input, text_input], outputs=output) 107 | test_button.click(fn=test_update_map, inputs=[plot_input, text_input], outputs=output) 108 | save_button.click(fn=save_writer, inputs=[], outputs=output) 109 | 110 | demo.launch() 111 | 112 | if __name__ == "__main__": 113 | run_gradio_interface() 114 | --------------------------------------------------------------------------------