{"repo": "bmxh-001/bmxh1.06", "files": {"app.py": {"a92648da004551f4dfee8a0a9a5992ba2d624b8b": {"t": 1785747496.130773, "n": 101696, "h": "ece1077810bb8d47", "c": "import requests\nimport os\nimport uuid\nimport sys\nfrom flask import Flask, request, jsonify, render_template, Response, stream_with_context\nimport re\nimport tempfile\nimport json\nimport logging\nfrom docx import Document\nfrom docx.shared import Inches\nimport datetime\nimport io\nimport warnings # Added for API key warning\nimport re\nimport tempfile\nimport base64\nimport networkx as nx\nimport matplotlib.pyplot as plt\nfrom io import BytesIO\nimport mimetypes\n\n# 添加对PyInstaller打包的支持\ndef resource_path(relative_path):\n \"\"\"获取资源的绝对路径,支持开发环境和PyInstaller打包后的环境\"\"\"\n try:\n # PyInstaller创建临时文件夹并将路径存储在_MEIPASS中\n base_path = sys._MEIPASS\n except Exception:\n base_path = os.path.abspath(\".\")\n \n return os.path.join(base_path, relative_path)\n\napp = Flask(__name__, \n template_folder=resource_path('templates'),\n static_folder=resource_path('static'))\n\n@app.after_request\ndef set_js_mime_type(response):\n if request.path.endswith('.js'):\n response.headers['Content-Type'] = 'application/javascript'\n return response\n\n# 设置日志\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\n\n# Configuration\nOPENROUTER_API_BASE = \"https://openrouter.ai/api/v1\" # OpenRouter Base URL\nOPENROUTER_API_KEY = None # Will be set via API endpoint\nCUSTOM_API_CONFIGS = {} # Will store custom API configurations in memory\n\n# 确定是否在打包环境中运行\nif getattr(sys, 'frozen', False):\n # 如果是打包后的EXE,使用应用程序所在目录\n application_path = os.path.dirname(sys.executable)\n EXPORT_DIR = os.path.join(application_path, 'exports')\n DATA_DIR = os.path.join(application_path, 'data') # 数据文件目录\n MODEL_DIR = os.path.join(application_path, 'model') # 新增:模型模板文件夹\nelse:\n # 正常开发环境\n EXPORT_DIR = os.path.join(os.path.dirname(__file__), 'exports')\n DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') # 数据文件目录\n MODEL_DIR = os.path.join(os.path.dirname(__file__), 'model') # 新增:模型模板文件夹\n\n# 数据文件路径\nNOVELS_FILE = os.path.join(DATA_DIR, 'novels.json')\nCHARACTERS_FILE = os.path.join(DATA_DIR, 'characters.json')\nGLOSSARY_FILE = os.path.join(DATA_DIR, 'glossary.json')\nSTYLES_FILE = os.path.join(DATA_DIR, 'styles.json')\nGAMES_FILE = os.path.join(DATA_DIR, 'games.json')\nAPI_CONFIGS_FILE = os.path.join(DATA_DIR, 'api_configs.json') # 自定义API配置文件\nAPI_KEY_FILE = os.path.join(DATA_DIR, 'api_key.json') # OpenRouter API密钥文件\n\n# Simple in-memory storage for novels (replace with a database for persistence)\nnovels_db = {}\n# Simple in-memory storage for character library\ncharacters_db = {}\n# Simple in-memory storage for glossary entries\nglossary_db = {}\n# Simple in-memory storage for style library\nstyles_db = {}\n# Simple in-memory storage for games\ngames_db = {}\n\n# Ensure export and data directories exist\nif not os.path.exists(EXPORT_DIR):\n os.makedirs(EXPORT_DIR)\nif not os.path.exists(DATA_DIR):\n os.makedirs(DATA_DIR)\nif not os.path.exists(MODEL_DIR): # 新增:确保模型文件夹存在\n os.makedirs(MODEL_DIR)\n\n# 加载持久化数据\ndef load_data():\n \"\"\"从JSON文件加载数据到内存\"\"\"\n global novels_db, characters_db, glossary_db, styles_db, games_db, CUSTOM_API_CONFIGS, OPENROUTER_API_KEY\n \n logging.info(\"尝试加载持久化数据...\")\n logging.info(f\"小说数据文件路径: {NOVELS_FILE}\")\n\n # 确保所有数据目录存在\n for directory in [DATA_DIR, EXPORT_DIR, MODEL_DIR]:\n if not os.path.exists(directory):\n os.makedirs(directory)\n logging.info(f\"创建目录: {directory}\")\n\n # 确保所有数据文件存在\n data_files = {\n NOVELS_FILE: {},\n CHARACTERS_FILE: {},\n GLOSSARY_FILE: {},\n STYLES_FILE: {},\n GAMES_FILE: {},\n API_CONFIGS_FILE: {},\n API_KEY_FILE: {\"api_key\": None}\n }\n\n for file_path, default_content in data_files.items():\n if not os.path.exists(file_path):\n try:\n with open(file_path, 'w', encoding='utf-8') as f:\n json.dump(default_content, f, ensure_ascii=False, indent=2)\n logging.info(f\"创建数据文件: {file_path}\")\n except Exception as e:\n logging.error(f\"创建数据文件失败 {file_path}: {e}\")\n\n # 加载小说数据\n if os.path.exists(NOVELS_FILE):\n logging.info(\"小说数据文件存在,尝试加载...\")\n try:\n with open(NOVELS_FILE, 'r', encoding='utf-8') as f:\n novels_db = json.load(f)\n logging.info(f\"成功加载了 {len(novels_db)} 部小说。\")\n # 添加日志,打印加载后的小说ID列表和总数\n logging.info(f\"加载后的小说ID列表 (前5个): {list(novels_db.keys())[:5]}\")\n logging.info(f\"加载后的小说总数: {len(novels_db)}\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载小说数据出错 (JSON解析错误): {e}\")\n novels_db = {}\n except Exception as e:\n logging.error(f\"加载小说数据出错 (其他错误): {e}\")\n novels_db = {}\n else:\n logging.info(\"小说数据文件不存在,初始化为空数据库。\")\n novels_db = {}\n \n \n # 加载角色数据\n if os.path.exists(CHARACTERS_FILE):\n try:\n with open(CHARACTERS_FILE, 'r', encoding='utf-8') as f:\n characters_db = json.load(f)\n logging.info(f\"成功加载了 {len(characters_db)} 个角色。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载角色数据出错 (JSON解析错误): {e}\")\n characters_db = {}\n except Exception as e:\n logging.error(f\"加载角色数据出错 (其他错误): {e}\")\n characters_db = {}\n else:\n logging.info(\"角色数据文件不存在,初始化为空数据库。\")\n characters_db = {}\n \n # 加载词条数据\n if os.path.exists(GLOSSARY_FILE):\n try:\n with open(GLOSSARY_FILE, 'r', encoding='utf-8') as f:\n glossary_db = json.load(f)\n logging.info(f\"成功加载了 {len(glossary_db)} 个词条。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载词条数据出错 (JSON解析错误): {e}\")\n glossary_db = {}\n except Exception as e:\n logging.error(f\"加载词条数据出错 (其他错误): {e}\")\n glossary_db = {}\n else:\n logging.info(\"词条数据文件不存在,初始化为空数据库。\")\n glossary_db = {}\n \n # 加载风格数据\n if os.path.exists(STYLES_FILE):\n try:\n with open(STYLES_FILE, 'r', encoding='utf-8') as f:\n styles_db = json.load(f)\n logging.info(f\"成功加载了 {len(styles_db)} 个风格词条。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载风格数据出错 (JSON解析错误): {e}\")\n styles_db = {}\n except Exception as e:\n logging.error(f\"加载风格数据出错 (其他错误): {e}\")\n styles_db = {}\n else:\n logging.info(\"风格数据文件不存在,初始化为空数据库。\")\n styles_db = {}\n \n # 加载游戏数据\n if os.path.exists(GAMES_FILE):\n try:\n with open(GAMES_FILE, 'r', encoding='utf-8') as f:\n games_db = json.load(f)\n logging.info(f\"成功加载了 {len(games_db)} 个游戏记录。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载游戏数据出错 (JSON解析错误): {e}\")\n games_db = {}\n except Exception as e:\n logging.error(f\"加载游戏数据出错 (其他错误): {e}\")\n games_db = {}\n else:\n logging.info(\"游戏数据文件不存在,初始化为空数据库。\")\n games_db = {}\n \n # 加载自定义API配置\n if os.path.exists(API_CONFIGS_FILE):\n try:\n with open(API_CONFIGS_FILE, 'r', encoding='utf-8') as f:\n CUSTOM_API_CONFIGS = json.load(f)\n logging.info(f\"成功加载了 {len(CUSTOM_API_CONFIGS)} 个自定义API配置。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载自定义API配置出错 (JSON解析错误): {e}\")\n CUSTOM_API_CONFIGS = {}\n except Exception as e:\n logging.error(f\"加载自定义API配置出错 (其他错误): {e}\")\n CUSTOM_API_CONFIGS = {}\n else:\n logging.info(\"自定义API配置文件不存在,初始化为空配置。\")\n CUSTOM_API_CONFIGS = {}\n \n # 加载OpenRouter API密钥\n if os.path.exists(API_KEY_FILE):\n try:\n with open(API_KEY_FILE, 'r', encoding='utf-8') as f:\n api_key_data = json.load(f)\n OPENROUTER_API_KEY = api_key_data.get(\"api_key\")\n if OPENROUTER_API_KEY:\n logging.info(\"成功加载了API密钥。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载API密钥出错 (JSON解析错误): {e}\")\n OPENROUTER_API_KEY = None\n except Exception as e:\n logging.error(f\"加载API密钥出错 (其他错误): {e}\")\n OPENROUTER_API_KEY = None\n else:\n print(\"API密钥文件不存在或密钥未设置。\")\n OPENROUTER_API_KEY = None\n\n# 保存数据到文件\ndef save_novels():\n \"\"\"保存小说数据到文件\"\"\"\n logging.info(\"尝试保存小说数据...\")\n logging.info(f\"小说数据文件路径: {NOVELS_FILE}\")\n try:\n # 使用临时文件进行原子性写入\n with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False, dir=os.path.dirname(NOVELS_FILE)) as tmp_file:\n json.dump(novels_db, tmp_file, ensure_ascii=False, indent=2)\n # 原子性替换原文件\n os.replace(tmp_file.name, NOVELS_FILE)\n logging.info(f\"成功保存了 {len(novels_db)} 部小说。\")\n return True\n except Exception as e:\n logging.error(f\"保存小说数据出错: {e}\")\n # 如果发生错误,尝试清理临时文件\n if 'tmp_file' in locals() and os.path.exists(tmp_file.name):\n os.remove(tmp_file.name)\n return False\n\ndef save_characters():\n \"\"\"保存角色数据到文件\"\"\"\n try:\n with open(CHARACTERS_FILE, 'w', encoding='utf-8') as f:\n json.dump(characters_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(characters_db)} 个角色。\")\n return True\n except Exception as e:\n print(f\"保存角色数据出错: {e}\")\n return False\n\ndef save_glossary():\n \"\"\"保存词条数据到文件\"\"\"\n try:\n with open(GLOSSARY_FILE, 'w', encoding='utf-8') as f:\n json.dump(glossary_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(glossary_db)} 个词条。\")\n return True\n except Exception as e:\n print(f\"保存词条数据出错: {e}\")\n return False\n\ndef save_styles():\n \"\"\"保存风格数据到文件\"\"\"\n try:\n with open(STYLES_FILE, 'w', encoding='utf-8') as f:\n json.dump(styles_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(styles_db)} 个风格词条。\")\n return True\n except Exception as e:\n print(f\"保存风格数据出错: {e}\")\n return False\n\ndef save_games():\n \"\"\"保存游戏数据到文件\"\"\"\n try:\n with open(GAMES_FILE, 'w', encoding='utf-8') as f:\n json.dump(games_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(games_db)} 个游戏记录。\")\n return True\n except Exception as e:\n print(f\"保存游戏数据出错: {e}\")\n return False\n\ndef save_api_configs():\n \"\"\"保存自定义API配置到文件\"\"\"\n try:\n with open(API_CONFIGS_FILE, 'w', encoding='utf-8') as f:\n json.dump(CUSTOM_API_CONFIGS, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(CUSTOM_API_CONFIGS)} 个自定义API配置。\")\n return True\n except Exception as e:\n print(f\"保存自定义API配置出错: {e}\")\n return False\n\ndef save_api_key():\n \"\"\"保存OpenRouter API密钥到文件\"\"\"\n try:\n with open(API_KEY_FILE, 'w', encoding='utf-8') as f:\n json.dump({\"api_key\": OPENROUTER_API_KEY}, f, ensure_ascii=False, indent=2)\n print(\"成功保存了API密钥。\")\n return True\n except Exception as e:\n print(f\"保存API密钥出错: {e}\")\n return False\n\n# 在应用启动时加载数据\nload_data()\n\n# --- Helper Functions ---\n\n# Renamed function and updated to return specified OpenRouter models\ndef get_available_models():\n \"\"\"Returns a list of available models (currently hardcoded for OpenRouter).\"\"\"\n # In the future, this could potentially fetch from OpenRouter, but for now,\n # we list the specified free models.\n return [\n {\"id\": \"deepseek/deepseek-chat-v3-0324:free\", \"name\": \"Deepseek/deepseek-chat-v3-0324:free (Free)\"},\n {\"id\": \"qwen/qwen3-235b-a22b:free\", \"name\": \"Qwen/qwen3-235b-a22b:free (Free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Google Gemma 3 27b IT (Free)\"},\n {\"id\": \"qwen/qwen2.5-vl-72b-instruct:free\", \"name\": \"qwen/qwen2.5-vl-72b-instruct:free\"},\n {\"id\": \"meta-llama/llama-4-scout:free\", \"name\": \"Meta-llama/llama-4-scout:free (Free)\"},\n {\"id\": \"google/gemini-2.0-flash-exp:free\", \"name\": \"Google/gemini-2.0-flash-exp:free (Free)\"},\n {\"id\": \"deepseek/deepseek-r1:free\", \"name\": \"DeepSeek Coder R1 (Free)\"}\n ]\n # --- Removed Ollama fetching logic ---\n # try:\n # response = requests.get(f\"{OLLAMA_BASE_URL}/api/tags\")\n # response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)\n # models_data = response.json()\n # # Extract just the model names\n # model_names = [model['name'] for model in models_data.get('models', [])]\n # return model_names\n # except requests.exceptions.RequestException as e:\n # print(f\"Error connecting to Ollama: {e}\")\n # return None # Indicate failure\n # except json.JSONDecodeError:\n # print(f\"Error decoding JSON response from Ollama: {response.text}\")\n # return None\n # except Exception as e:\n # print(f\"An unexpected error occurred while fetching models: {e}\")\n # return None\n\n# --- API Endpoints ---\n\n@app.route('/')\ndef index():\n \"\"\"Serves the main HTML page.\"\"\"\n return render_template('index.html')\n\n@app.route('/api/models', methods=['GET'])\ndef list_models():\n \"\"\"API endpoint to get the list of available models.\"\"\"\n # Get OpenRouter models\n openrouter_models = get_available_models()\n \n # Get custom API models from configurations\n custom_models = []\n for api_id, config in CUSTOM_API_CONFIGS.items():\n custom_models.append({\n \"id\": f\"custom-{api_id}\", \n \"name\": f\"{config['name']} - {config['modelName']}\"\n })\n \n # Combine both lists\n all_models = openrouter_models + custom_models\n \n if all_models:\n return jsonify(all_models)\n else:\n return jsonify({\"error\": \"Could not retrieve model list.\"}), 500\n\n@app.route('/api/novels', methods=['GET'])\ndef get_novels():\n \"\"\"API endpoint to list all novels.\"\"\"\n # Return a list of novels with id and title\n novel_list = [{\"id\": novel_id, \"title\": data.get(\"title\", \"Untitled\")} for novel_id, data in novels_db.items()]\n return jsonify(novel_list)\n\n@app.route('/api/novels', methods=['POST'])\ndef create_novel():\n \"\"\"API endpoint to create a new novel.\"\"\"\n data = request.get_json()\n if not data or 'title' not in data:\n return jsonify({\"message\": \"Title is required\"}), 400\n\n novel_id = str(uuid.uuid4()) # Generate a unique ID\n new_novel = {\n \"title\": data['title'],\n \"content\": data.get('content', ''), # Initialize with empty content or provided content\n \"characters\": \"\", # Initialize text field\n \"knowledge\": \"\",\n \"style_prompt\": \"\", # Initialize text field\n \"character_tags\": [], # Initialize tag arrays\n \"glossary_tags\": [],\n \"style_tags\": []\n # Add other metadata later (e.g., settings, characters)\n }\n novels_db[novel_id] = new_novel\n logging.info(f\"Created novel in memory: {novel_id} - {data['title']}\")\n \n if save_novels(): # 保存小说数据并检查结果\n logging.info(f\"Successfully saved novel {novel_id} to file.\")\n return jsonify({\"id\": novel_id, \"title\": new_novel[\"title\"]}), 201 # 201 Created status\n else:\n logging.error(f\"Failed to save novel {novel_id} to file.\")\n # Decide how to handle save failure - maybe return a 500 error?\n # For now, we'll still return 201 as the novel is in memory, but logging indicates the issue.\n # A more robust solution might require rolling back the in-memory addition or returning an error.\n # Given the current structure, logging the error is the most direct fix based on the proposal.\n return jsonify({\"id\": novel_id, \"title\": new_novel[\"title\"], \"warning\": \"Failed to save novel to file\"}), 201 # Still return 201 but add a warning\n\n@app.route('/api/novels/', methods=['GET'])\ndef get_novel(novel_id):\n \"\"\"API endpoint to get details of a specific novel.\"\"\"\n novel = novels_db.get(novel_id)\n if novel:\n return jsonify({\n \"id\": novel_id,\n \"title\": novel.get(\"title\", \"Untitled\"),\n \"content\": novel.get(\"content\", \"\"),\n \"characters\": novel.get(\"characters\", \"\"), # Return text field\n \"knowledge\": novel.get(\"knowledge\", \"\"),\n \"style_prompt\": novel.get(\"style_prompt\", \"\"), # Return text field\n \"character_tags\": novel.get(\"character_tags\", []), # Return tag arrays\n \"glossary_tags\": novel.get(\"glossary_tags\", []),\n \"style_tags\": novel.get(\"style_tags\", [])\n # Add other fields as needed\n })\n else:\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n@app.route('/api/novels/', methods=['PUT', 'PATCH'])\ndef update_novel(novel_id):\n \"\"\"API endpoint to update a novel's content (or other fields).\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"message\": \"No update data provided\"}), 400\n\n # Update fields provided in the request\n if 'title' in data:\n novels_db[novel_id]['title'] = data['title']\n if 'content' in data:\n novels_db[novel_id]['content'] = data['content']\n # Add updates for metadata text fields\n if 'characters' in data:\n novels_db[novel_id]['characters'] = data['characters']\n if 'knowledge' in data:\n novels_db[novel_id]['knowledge'] = data['knowledge']\n if 'style_prompt' in data:\n novels_db[novel_id]['style_prompt'] = data['style_prompt']\n # Add updates for tag arrays\n if 'character_tags' in data:\n novels_db[novel_id]['character_tags'] = data['character_tags']\n if 'glossary_tags' in data:\n novels_db[novel_id]['glossary_tags'] = data['glossary_tags']\n if 'style_tags' in data:\n novels_db[novel_id]['style_tags'] = data['style_tags']\n\n print(f\"Updated novel: {novel_id}\")\n save_novels() # 保存小说数据\n return jsonify({\"message\": \"Novel updated successfully\", \"id\": novel_id})\n\n@app.route('/api/novels/', methods=['DELETE'])\ndef delete_novel(novel_id):\n \"\"\"API endpoint to delete a specific novel.\"\"\"\n if novel_id in novels_db:\n deleted_title = novels_db[novel_id].get('title', 'Untitled')\n del novels_db[novel_id]\n print(f\"Deleted novel: {novel_id} - {deleted_title}\")\n save_novels() # 保存小说数据\n return jsonify({\"message\": f\"Novel '{deleted_title}' deleted successfully\"})\n else:\n print(f\"Attempted to delete non-existent novel: {novel_id}\")\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n@app.route('/api/set-api-key', methods=['POST'])\ndef set_api_key():\n \"\"\"API endpoint to set and validate the OpenRouter API key.\"\"\"\n global OPENROUTER_API_KEY\n data = request.get_json()\n if not data or 'api_key' not in data:\n return jsonify({\"error\": \"请输入API密钥\"}), 400\n \n api_key = data['api_key'].strip()\n if not api_key:\n return jsonify({\"error\": \"API密钥不能为空\"}), 400\n \n # 验证API密钥格式\n if not api_key.startswith('sk-'):\n return jsonify({\"error\": \"无效的API密钥格式,OpenRouter API密钥应以'sk-'开头\"}), 400\n \n # 设置API密钥\n OPENROUTER_API_KEY = api_key\n \n # 保存API密钥到文件\n save_api_key()\n \n return jsonify({\"message\": \"API密钥已成功设置并保存\"})\n\n@app.route('/api/api-key-status', methods=['GET'])\ndef get_api_key_status():\n \"\"\"API endpoint to check if an API key is set.\"\"\"\n has_key = OPENROUTER_API_KEY is not None and len(str(OPENROUTER_API_KEY)) > 0\n \n key_preview = None\n if has_key and len(str(OPENROUTER_API_KEY)) >= 11: # 确保密钥长度足够生成预览\n key_preview = f\"{OPENROUTER_API_KEY[:7]}...{OPENROUTER_API_KEY[-4:]}\"\n \n return jsonify({\n \"has_key\": has_key,\n \"key_preview\": key_preview\n })\n\n@app.route('/api/generate', methods=['POST'])\ndef generate_text():\n \"\"\"API endpoint to generate text using OpenRouter or custom APIs.\"\"\"\n data = request.get_json()\n if not data or 'model' not in data or 'prompt' not in data:\n return jsonify({\"error\": \"Missing required fields: model and prompt\"}), 400\n\n model = data['model']\n prompt = data['prompt']\n temperature = data.get('temperature', 0.7) # Default temperature to 0.7\n\n # Check if this is a custom API model\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"Custom API configuration not found\"}), 404\n \n # Get the API configuration\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config['path'] or '' # Use empty string if path is not provided\n # --- NEW URL JOINING LOGIC ---\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n if clean_path:\n api_url = f\"{clean_base_url}/{clean_path}\"\n else:\n api_url = clean_base_url # Use base URL directly if path is empty\n # --- END NEW LOGIC ---\n secret = api_config['secret']\n model_name = api_config['modelName']\n \n # Prepare headers based on configuration\n headers = {\n \"Content-Type\": \"application/json\"\n }\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n # Prepare payload for custom API (assuming OpenAI-like format)\n custom_payload = {\n \"model\": model_name,\n \"messages\": [\n {\"role\": \"user\", \"content\": prompt}\n ],\n \"temperature\": float(temperature),\n \"stream\": True\n }\n \n # 调试用,打印请求信息\n print('请求URL:', api_url)\n print('请求Headers:', headers)\n print('请求Body:', json.dumps(custom_payload, ensure_ascii=False))\n \n try:\n # Create streaming response\n def event_stream():\n try:\n response = requests.post(\n api_url, \n headers=headers, \n json=custom_payload, \n stream=True\n )\n response.raise_for_status()\n \n # Stream the response\n for line in response.iter_lines():\n if line:\n decoded_line = line.decode('utf-8')\n if decoded_line.startswith('data: '):\n data_content = decoded_line[len('data: '):]\n if data_content.strip() == '[DONE]':\n print(\"Stream finished.\")\n break\n try:\n chunk = json.loads(data_content)\n # Extract token from response format\n if 'choices' in chunk and len(chunk['choices']) > 0:\n delta = chunk['choices'][0].get('delta', {})\n # 兼容 ModelScope reasoning_content\n reasoning = delta.get('reasoning_content')\n token = delta.get('content')\n if reasoning:\n yield f\"data: {json.dumps({'reasoning': reasoning})}\\n\\n\"\n if token:\n yield f\"data: {json.dumps({'token': token})}\\n\\n\"\n \n finish_reason = chunk['choices'][0].get('finish_reason')\n if finish_reason:\n print(f\"--> Stream finished with reason: {finish_reason}\")\n break\n except json.JSONDecodeError:\n print(f\"Error decoding stream JSON chunk: {data_content}\")\n elif decoded_line.strip():\n print(f\"Received non-data line: {decoded_line}\")\n except requests.exceptions.RequestException as e:\n error_message = f\"Error connecting to custom API: {e}\"\n print(error_message)\n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"Custom API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via custom API. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via custom API. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n \n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n except Exception as e:\n error_message = f\"An unexpected error occurred during custom API generation: {e}\"\n print(error_message)\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n finally:\n print(\"Closing stream generator.\")\n \n return Response(stream_with_context(event_stream()), mimetype='text/event-stream')\n except Exception as e:\n print(f\"Error setting up custom API request: {e}\")\n return jsonify({\"error\": f\"Failed to generate text via custom API: {str(e)}\"}), 500\n \n # If not a custom API, use OpenRouter (existing logic)\n if not OPENROUTER_API_KEY:\n return jsonify({\"error\": \"请先设置OpenRouter API密钥。您可以在页面顶部的设置区域中输入并保存API密钥。\"}), 401\n\n # --- OpenRouter API Request Logic ---\n openrouter_payload = {\n \"model\": model,\n \"messages\": [\n # OpenRouter uses a 'messages' array, typically with roles\n {\"role\": \"user\", \"content\": prompt}\n # Add system prompts or previous conversation history here if needed\n ],\n \"temperature\": float(temperature),\n \"stream\": True, # Enable streaming from OpenRouter\n # Add other OpenRouter parameters here if needed (e.g., max_tokens)\n }\n\n headers = {\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n # OpenRouter might suggest adding HTTP Referer or X-Title headers\n # \"HTTP-Referer\": $YOUR_SITE_URL, # Optional\n # \"X-Title\": $YOUR_SITE_NAME # Optional\n }\n\n try:\n # --- Streaming Response Handling ---\n def event_stream():\n try:\n # Use the OpenRouter chat completions endpoint with streaming enabled\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\", \n headers=headers, \n json=openrouter_payload, \n stream=True # Enable streaming in requests\n )\n response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)\n\n # Iterate over the response lines\n for line in response.iter_lines():\n if line:\n decoded_line = line.decode('utf-8')\n if decoded_line.startswith('data: '):\n data_content = decoded_line[len('data: '):]\n if data_content.strip() == '[DONE]': # Check for OpenRouter stream end signal\n print(\"Stream finished.\")\n break\n try:\n chunk = json.loads(data_content)\n # Extract token from OpenRouter's streaming format\n # Typically: chunk['choices'][0]['delta']['content']\n if 'choices' in chunk and len(chunk['choices']) > 0:\n delta = chunk['choices'][0].get('delta', {})\n # 兼容 ModelScope reasoning_content\n reasoning = delta.get('reasoning_content')\n token = delta.get('content')\n if reasoning:\n yield f\"data: {json.dumps({'reasoning': reasoning})}\\n\\n\"\n if token:\n yield f\"data: {json.dumps({'token': token})}\\n\\n\"\n \n finish_reason = chunk['choices'][0].get('finish_reason')\n if finish_reason:\n print(f\"--> Stream finished with reason: {finish_reason}\") # Enhanced logging\n # Optionally send a final signal\n # yield f\"data: {json.dumps({'event': 'finish', 'reason': finish_reason})}\\n\\n\"\n break # Stop sending after finish reason\n\n except json.JSONDecodeError:\n print(f\"Error decoding stream JSON chunk: {data_content}\")\n # yield f\"data: {json.dumps({'error': 'Invalid JSON chunk'})}\\n\\n\"\n # Continue to next line\n elif decoded_line.strip(): # Log other non-empty lines for debugging\n print(f\"Received non-data line: {decoded_line}\")\n\n except requests.exceptions.RequestException as e:\n # Handle connection errors, timeouts, etc.\n error_message = f\"Error connecting to OpenRouter: {e}\"\n print(error_message)\n # Try to get more specific error from response if available and it's a JSON response\n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"OpenRouter API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via OpenRouter. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via OpenRouter. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n \n # Send error back via SSE\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n except Exception as e:\n error_message = f\"An unexpected error occurred during OpenRouter generation: {e}\"\n print(error_message)\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n finally:\n # Ensure a final signal is sent if needed, or just close\n print(\"Closing stream generator.\")\n # yield f\"data: [DONE]\\n\\n\" # Optionally send a custom end signal\n\n # Return the streaming response\n return Response(stream_with_context(event_stream()), mimetype='text/event-stream')\n\n except requests.exceptions.RequestException as e:\n # Handle connection errors, timeouts, etc.\n error_message = f\"Error connecting to OpenRouter: {e}\"\n print(error_message)\n # Try to get more specific error from response if available and it's a JSON response\n # --- This error handling is for the initial request, not the stream itself ---\n # --- Stream errors are handled within event_stream --- \n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"OpenRouter API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via OpenRouter. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via OpenRouter. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n\n return jsonify({\"error\": error_message}), 500\n except json.JSONDecodeError:\n print(f\"Error decoding JSON response from OpenRouter generation: {response.text}\")\n return jsonify({\"error\": \"Invalid response format from OpenRouter generation.\"}), 500\n except Exception as e:\n print(f\"An unexpected error occurred during OpenRouter generation: {e}\")\n return jsonify({\"error\": f\"An unexpected error occurred: {str(e)}\"}), 500\n\n# --- Export Endpoints (Server-Side) ---\n\ndef generate_export_filename(title, extension):\n \"\"\"Generates a sanitized filename with title and timestamp.\"\"\"\n # Sanitize title (allow alphanumeric and CJK)\n sanitized_title = ''.join(c for c in title if c.isalnum() or '\\u4e00' <= c <= '\\u9fff')\n sanitized_title = sanitized_title or \"Untitled\" # Fallback if title is empty after sanitizing\n timestamp = datetime.datetime.now().strftime(\"%Y-%m-%dT%H-%M-%S\")\n return f\"{sanitized_title}_{timestamp}.{extension}\"\n\n@app.route('/api/novels//export/', methods=['POST'])\ndef export_novel_serverside(novel_id, file_format):\n \"\"\"API endpoint to export a novel to a file on the server.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n\n novel_data = novels_db[novel_id]\n title = novel_data.get('title', 'Untitled')\n content = novel_data.get('content', '')\n characters = novel_data.get('characters', '')\n knowledge = novel_data.get('knowledge', '')\n style_prompt = novel_data.get('style_prompt', '')\n character_tags = novel_data.get('character_tags', [])\n glossary_tags = novel_data.get('glossary_tags', [])\n style_tags = novel_data.get('style_tags', [])\n\n try:\n if file_format == 'txt':\n filename = generate_export_filename(title, 'txt')\n filepath = os.path.join(EXPORT_DIR, filename)\n txt_content = f\"标题: {title}\\n\\n\"\n if characters: txt_content += f\"角色设定:\\n{characters}\\n\\n\"\n if style_prompt: txt_content += f\"风格提示:\\n{style_prompt}\\n\\n\"\n if knowledge: txt_content += f\"关联知识:\\n{knowledge}\\n\\n\"\n txt_content += f\"正文内容:\\n{content}\"\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(txt_content)\n\n elif file_format == 'json':\n filename = generate_export_filename(title, 'json')\n filepath = os.path.join(EXPORT_DIR, filename)\n export_data = {\n 'title': title,\n 'content': content,\n 'characters': characters,\n 'knowledge': knowledge,\n 'style_prompt': style_prompt,\n 'character_tags': character_tags,\n 'glossary_tags': glossary_tags,\n 'style_tags': style_tags\n }\n with open(filepath, 'w', encoding='utf-8') as f:\n json.dump(export_data, f, ensure_ascii=False, indent=4)\n\n elif file_format == 'docx':\n filename = generate_export_filename(title, 'docx')\n filepath = os.path.join(EXPORT_DIR, filename)\n\n document = Document()\n document.add_heading(title, level=0) # Title\n document.add_paragraph()\n\n if characters:\n document.add_heading('角色设定', level=2)\n for line in characters.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n if style_prompt:\n document.add_heading('风格提示', level=2)\n for line in style_prompt.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n if knowledge:\n document.add_heading('关联知识', level=2)\n for line in knowledge.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n\n document.add_heading('正文内容', level=1)\n for line in content.split('\\n'): # Add content line by line\n document.add_paragraph(line)\n\n document.save(filepath)\n\n else:\n return jsonify({\"error\": \"Invalid file format requested\"}), 400\n\n print(f\"Exported novel '{title}' ({novel_id}) to {filepath}\")\n # Return path relative to server script for confirmation\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\"message\": f\"文件已成功导出至服务器: {relative_path}\", \"filepath\": relative_path})\n\n except Exception as e:\n print(f\"Error exporting novel {novel_id} to {file_format}: {e}\")\n return jsonify({\"error\": f\"导出文件时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/novels//export-notes', methods=['POST'])\ndef export_novel_notes(novel_id):\n \"\"\"API endpoint to export novel notes to a file in the exports folder.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data:\n logging.error(f\"[笔记导出] 未提供content,novel_id={novel_id}\")\n return jsonify({\"error\": \"No content provided\"}), 400\n\n try:\n # Create exports directory if it doesn't exist\n os.makedirs(EXPORT_DIR, exist_ok=True)\n\n # Generate filename with timestamp\n from datetime import datetime\n timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')\n filename = f'novel_notes_{novel_id}_{timestamp}.txt'\n filepath = os.path.join(EXPORT_DIR, filename)\n\n # Write content to file\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(data['content'])\n\n logging.info(f\"[笔记导出] 成功: {filepath}\")\n return jsonify({\n \"message\": f\"笔记已成功导出到: {filename}\",\n \"filepath\": filepath\n })\n\n except Exception as e:\n logging.error(f\"[笔记导出] Error exporting notes for novel {novel_id}: {str(e)}\")\n return jsonify({\"error\": f\"导出笔记失败: {str(e)}\"}), 500\n\n# --- Import Endpoint ---\n\n@app.route('/api/novels/import', methods=['POST'])\ndef import_novel():\n \"\"\"API endpoint to import a novel from uploaded file content.\"\"\"\n data = request.get_json()\n if not data or 'filename' not in data or 'content' not in data:\n return jsonify({\"error\": \"Missing filename or content\"}), 400\n\n filename = data['filename']\n file_content = data['content']\n file_ext = filename.split('.')[-1].lower()\n\n novel_data = {\n \"title\": \"Untitled Import\",\n \"content\": \"\",\n \"characters\": \"\",\n \"knowledge\": \"\",\n \"style_prompt\": \"\",\n \"character_tags\": [],\n \"glossary_tags\": [],\n \"style_tags\": []\n }\n\n try:\n if file_ext == 'json':\n # For JSON files, check if the content is already a JSON string or needs parsing\n try:\n # Try parsing the JSON content\n imported_data = json.loads(strip_code_block(file_content))\n novel_data['title'] = imported_data.get('title', f\"Imported {filename}\")\n novel_data['content'] = imported_data.get('content', '')\n novel_data['characters'] = imported_data.get('characters', '')\n novel_data['knowledge'] = imported_data.get('knowledge', '')\n novel_data['style_prompt'] = imported_data.get('style_prompt', '')\n # Import tag arrays if present\n novel_data['character_tags'] = imported_data.get('character_tags', [])\n novel_data['glossary_tags'] = imported_data.get('glossary_tags', [])\n novel_data['style_tags'] = imported_data.get('style_tags', [])\n except json.JSONDecodeError as json_error:\n # More detailed error message for JSON parsing issues\n detail = str(json_error)\n print(f\"JSON parse error: {detail}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无效的JSON格式: {detail}\"}), 400\n\n elif file_ext == 'txt':\n lines = file_content.splitlines()\n current_section = None\n content_buffer = []\n characters_buffer = []\n knowledge_buffer = []\n style_buffer = []\n\n for line in lines:\n line_strip = line.strip()\n if line_strip.startswith(\"标题:\"):\n novel_data['title'] = line_strip.split(\":\", 1)[1].strip() or f\"Imported {filename}\"\n current_section = None\n elif line_strip == \"角色设定:\":\n current_section = \"characters\"\n elif line_strip == \"风格提示:\":\n current_section = \"style_prompt\"\n elif line_strip == \"关联知识:\":\n current_section = \"knowledge\"\n elif line_strip == \"正文内容:\":\n current_section = \"content\"\n elif current_section == \"characters\":\n characters_buffer.append(line)\n elif current_section == \"style_prompt\":\n style_buffer.append(line)\n elif current_section == \"knowledge\":\n knowledge_buffer.append(line)\n elif current_section == \"content\":\n content_buffer.append(line)\n elif current_section is None and novel_data['title'] == \"Untitled Import\": # Try to find title if not explicitly tagged\n if line_strip: # Use first non-empty line as title if no header found yet\n novel_data['title'] = line_strip\n current_section = \"maybe_content\" # Assume following lines might be content\n\n elif current_section == \"maybe_content\": # If title was inferred, assume rest is content until a proper header\n content_buffer.append(line)\n\n\n novel_data['content'] = \"\\n\".join(content_buffer).strip()\n novel_data['characters'] = \"\\n\".join(characters_buffer).strip()\n novel_data['knowledge'] = \"\\n\".join(knowledge_buffer).strip()\n novel_data['style_prompt'] = \"\\n\".join(style_buffer).strip()\n\n # Basic fallback if no headers found at all\n if not novel_data['content'] and not novel_data['characters'] and not novel_data['knowledge'] and not novel_data['style_prompt']:\n if novel_data['title'] == \"Untitled Import\":\n novel_data['title'] = f\"Imported {filename}\"\n novel_data['content'] = file_content # Put everything in content\n \n elif file_ext == 'docx':\n # For Word documents, we need to convert base64 content to binary\n import base64\n \n try:\n # Convert base64 content to binary\n binary_content = base64.b64decode(file_content)\n \n # Read the Word document\n doc = Document(io.BytesIO(binary_content))\n \n paragraphs = [p.text for p in doc.paragraphs]\n \n # Try to identify sections based on headings\n current_section = \"maybe_content\"\n content_buffer = []\n characters_buffer = []\n knowledge_buffer = []\n style_buffer = []\n \n # First check if the document has a title (first paragraph or heading)\n if paragraphs and paragraphs[0].strip():\n novel_data[\"title\"] = paragraphs[0].strip()\n # Skip the title in further processing\n paragraphs = paragraphs[1:]\n else:\n novel_data[\"title\"] = f\"Imported {filename}\"\n \n for p in paragraphs:\n p_strip = p.strip()\n \n # Check for section headers\n if p_strip.lower() in [\"角色设定\", \"角色\"]:\n current_section = \"characters\"\n continue\n elif p_strip.lower() in [\"风格提示\", \"风格\"]:\n current_section = \"style_prompt\"\n continue\n elif p_strip.lower() in [\"关联知识\", \"知识\"]:\n current_section = \"knowledge\"\n continue\n elif p_strip.lower() in [\"正文内容\", \"正文\", \"内容\"]:\n current_section = \"content\"\n continue\n \n # Add paragraph to appropriate buffer\n if current_section == \"characters\":\n characters_buffer.append(p)\n elif current_section == \"style_prompt\":\n style_buffer.append(p)\n elif current_section == \"knowledge\":\n knowledge_buffer.append(p)\n elif current_section == \"content\" or current_section == \"maybe_content\":\n content_buffer.append(p)\n \n # Join the buffers and store in novel_data\n novel_data['content'] = \"\\n\".join(content_buffer).strip()\n novel_data['characters'] = \"\\n\".join(characters_buffer).strip()\n novel_data['knowledge'] = \"\\n\".join(knowledge_buffer).strip()\n novel_data['style_prompt'] = \"\\n\".join(style_buffer).strip()\n \n # If no structured content was found, put everything in content\n if not novel_data['content'] and not novel_data['characters'] and not novel_data['knowledge'] and not novel_data['style_prompt']:\n novel_data['content'] = \"\\n\".join(paragraphs).strip()\n except base64.binascii.Error as base64_error:\n print(f\"Base64 decode error: {str(base64_error)}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无法解码Word文档: {str(base64_error)}\"}), 400\n except Exception as docx_error:\n print(f\"DOCX processing error: {str(docx_error)}\")\n return jsonify({\"error\": f\"处理Word文档时出错: {str(docx_error)}\"}), 400\n\n else:\n return jsonify({\"error\": \"不支持的文件格式 (仅支持 .txt, .json, .docx)\"}), 400\n\n # 统一设置标题为\"用户导入\"\n novel_data['title'] = \"用户导入\"\n\n # Create new novel entry\n novel_id = str(uuid.uuid4())\n novels_db[novel_id] = novel_data\n print(f\"Imported novel: {novel_id} - {novel_data['title']}\")\n save_novels() # 保存小说数据\n\n # Return the details of the imported novel\n return jsonify({\n \"message\": \"Novel imported successfully\",\n \"id\": novel_id,\n \"title\": novel_data[\"title\"],\n \"content\": novel_data[\"content\"],\n \"characters\": novel_data[\"characters\"],\n \"knowledge\": novel_data[\"knowledge\"],\n \"style_prompt\": novel_data[\"style_prompt\"]\n }), 201\n\n except json.JSONDecodeError as json_error:\n error_message = f\"无效的JSON格式: {str(json_error)}\"\n print(f\"JSON parsing error: {error_message}\")\n return jsonify({\"error\": error_message}), 400\n except Exception as e:\n error_message = f\"导入小说时发生错误: {str(e)}\"\n print(f\"Error importing novel from {filename}: {e}\")\n return jsonify({\"error\": error_message}), 500\n\n# --- Character Library Endpoints ---\n\n@app.route('/api/characters', methods=['GET'])\ndef get_characters():\n \"\"\"API endpoint to list all characters in the library.\"\"\"\n # Return a list of characters with id and name\n character_list = [{\"id\": char_id, \"name\": data.get(\"name\", \"未命名\"), \"description\": data.get(\"description\", \"\")[:50] + \"...\" if len(data.get(\"description\", \"\")) > 50 else data.get(\"description\", \"\")} \n for char_id, data in characters_db.items()]\n return jsonify(character_list)\n\n@app.route('/api/characters', methods=['POST'])\ndef create_character():\n \"\"\"API endpoint to create a new character.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data:\n return jsonify({\"error\": \"角色名称是必填项\"}), 400\n\n character_id = str(uuid.uuid4()) # Generate a unique ID\n new_character = {\n \"name\": data['name'],\n \"description\": data.get('description', ''), # Character description\n \"details\": data.get('details', {}) # Additional details (could be skills, backstory, etc.)\n }\n characters_db[character_id] = new_character\n print(f\"Created character: {character_id} - {data['name']}\")\n save_characters() # 保存角色数据\n \n return jsonify({\n \"id\": character_id, \n \"name\": new_character[\"name\"],\n \"description\": new_character[\"description\"],\n \"details\": new_character[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/characters/', methods=['GET'])\ndef get_character(character_id):\n \"\"\"API endpoint to get details of a specific character.\"\"\"\n character = characters_db.get(character_id)\n if character:\n return jsonify({\n \"id\": character_id,\n \"name\": character.get(\"name\", \"未命名\"),\n \"description\": character.get(\"description\", \"\"),\n \"details\": character.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"角色不存在\"}), 404\n\n@app.route('/api/characters/', methods=['PUT', 'PATCH'])\ndef update_character(character_id):\n \"\"\"API endpoint to update a character's information.\"\"\"\n if character_id not in characters_db:\n return jsonify({\"error\": \"角色不存在\"}), 404\n \n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n \n # Update fields provided in the request\n if 'name' in data:\n characters_db[character_id]['name'] = data['name']\n if 'description' in data:\n characters_db[character_id]['description'] = data['description']\n if 'details' in data:\n characters_db[character_id]['details'] = data['details']\n \n save_characters() # 保存角色数据\n return jsonify({\n \"message\": \"角色已更新\", \n \"id\": character_id\n })\n\n@app.route('/api/characters/', methods=['DELETE'])\ndef delete_character(character_id):\n \"\"\"API endpoint to delete a specific character.\"\"\"\n if character_id in characters_db:\n deleted_name = characters_db[character_id].get('name', '未命名')\n del characters_db[character_id]\n print(f\"Deleted character: {character_id} - {deleted_name}\")\n save_characters() # 保存角色数据\n return jsonify({\"message\": f\"角色 '{deleted_name}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"角色不存在\"}), 404\n\n# --- Glossary Endpoints ---\n\n@app.route('/api/glossary', methods=['GET'])\ndef get_glossary_entries():\n \"\"\"API endpoint to list all glossary entries.\"\"\"\n # Return a list of glossary entries with id, term and brief description\n glossary_list = [{\"id\": entry_id, \n \"term\": data.get(\"term\", \"未命名词条\"), \n \"category\": data.get(\"category\", \"\"),\n \"description\": data.get(\"description\", \"\")[:50] + \"...\" if len(data.get(\"description\", \"\")) > 50 else data.get(\"description\", \"\")} \n for entry_id, data in glossary_db.items()]\n return jsonify(glossary_list)\n\n@app.route('/api/glossary', methods=['POST'])\ndef create_glossary_entry():\n \"\"\"API endpoint to create a new glossary entry.\"\"\"\n data = request.get_json()\n if not data or 'term' not in data:\n return jsonify({\"error\": \"词条名称是必填项\"}), 400\n\n entry_id = str(uuid.uuid4()) # Generate a unique ID\n new_entry = {\n \"term\": data['term'],\n \"category\": data.get('category', ''), # Category for organization\n \"description\": data.get('description', ''), # Description of the term\n \"details\": data.get('details', {}) # Additional details if needed\n }\n glossary_db[entry_id] = new_entry\n print(f\"Created glossary entry: {entry_id} - {data['term']}\")\n save_glossary() # 保存词条数据\n \n return jsonify({\n \"id\": entry_id, \n \"term\": new_entry[\"term\"],\n \"category\": new_entry[\"category\"],\n \"description\": new_entry[\"description\"],\n \"details\": new_entry[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/glossary/', methods=['GET'])\ndef get_glossary_entry(entry_id):\n \"\"\"API endpoint to get details of a specific glossary entry.\"\"\"\n entry = glossary_db.get(entry_id)\n if entry:\n return jsonify({\n \"id\": entry_id,\n \"term\": entry.get(\"term\", \"未命名词条\"),\n \"category\": entry.get(\"category\", \"\"),\n \"description\": entry.get(\"description\", \"\"),\n \"details\": entry.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n@app.route('/api/glossary/', methods=['PUT', 'PATCH'])\ndef update_glossary_entry(entry_id):\n \"\"\"API endpoint to update a glossary entry.\"\"\"\n if entry_id not in glossary_db:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n\n # Update fields provided in the request\n if 'term' in data:\n glossary_db[entry_id]['term'] = data['term']\n if 'category' in data:\n glossary_db[entry_id]['category'] = data['category']\n if 'description' in data:\n glossary_db[entry_id]['description'] = data['description']\n if 'details' in data:\n glossary_db[entry_id]['details'] = data['details']\n\n save_glossary() # 保存词条数据\n return jsonify({\n \"message\": \"词条已更新\", \n \"id\": entry_id\n })\n\n@app.route('/api/glossary/', methods=['DELETE'])\ndef delete_glossary_entry(entry_id):\n \"\"\"API endpoint to delete a specific glossary entry.\"\"\"\n if entry_id in glossary_db:\n deleted_term = glossary_db[entry_id].get('term', '未命名词条')\n del glossary_db[entry_id]\n print(f\"Deleted glossary entry: {entry_id} - {deleted_term}\")\n save_glossary() # 保存词条数据\n return jsonify({\"message\": f\"词条 '{deleted_term}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n@app.route('/api/novels//glossary', methods=['GET'])\ndef get_novel_glossary(novel_id):\n \"\"\"API endpoint to get glossary entries associated with a specific novel.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"小说不存在\"}), 404\n \n # In a more advanced implementation, we might have a relationship table\n # For now, we'll return all glossary entries (could be filtered by novel later)\n return get_glossary_entries()\n\n# --- Style Library Endpoints ---\n\n@app.route('/api/styles', methods=['GET'])\ndef get_styles():\n \"\"\"API endpoint to list all style entries.\"\"\"\n # Return a list of style entries with id, name and brief description\n style_list = [{\"id\": style_id, \n \"name\": data.get(\"name\", \"未命名风格\"), \n \"category\": data.get(\"category\", \"\"),\n \"content\": data.get(\"content\", \"\")[:50] + \"...\" if len(data.get(\"content\", \"\")) > 50 else data.get(\"content\", \"\")} \n for style_id, data in styles_db.items()]\n return jsonify(style_list)\n\n@app.route('/api/styles', methods=['POST'])\ndef create_style():\n \"\"\"API endpoint to create a new style entry.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data:\n return jsonify({\"error\": \"风格名称是必填项\"}), 400\n\n style_id = str(uuid.uuid4()) # Generate a unique ID\n new_style = {\n \"name\": data['name'],\n \"category\": data.get('category', ''), # Category for organization\n \"content\": data.get('content', ''), # The actual style prompt content\n \"details\": data.get('details', {}) # Additional details if needed\n }\n styles_db[style_id] = new_style\n print(f\"Created style entry: {style_id} - {data['name']}\")\n save_styles() # 保存风格数据\n \n return jsonify({\n \"id\": style_id, \n \"name\": new_style[\"name\"],\n \"category\": new_style[\"category\"],\n \"content\": new_style[\"content\"],\n \"details\": new_style[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/styles/import-md', methods=['POST'])\ndef import_md_as_style():\n \"\"\"API endpoint to import a markdown file from the prompt folder as a style entry.\"\"\"\n data = request.get_json()\n if not data or 'filename' not in data:\n return jsonify({\"error\": \"文件名是必填项\"}), 400\n\n try:\n # Determine the prompt folder path\n if getattr(sys, 'frozen', False):\n prompt_folder = os.path.join(os.path.dirname(sys.executable), 'prompt')\n else:\n prompt_folder = os.path.join(os.path.dirname(__file__), 'prompt')\n \n # Ensure the filename has the .md extension\n filename = data['filename']\n if not filename.lower().endswith('.md'):\n filename += '.md'\n \n file_path = os.path.join(prompt_folder, filename)\n \n if not os.path.exists(file_path):\n return jsonify({\"error\": f\"找不到文件: {filename}\"}), 404\n \n # Read the markdown file content\n with open(file_path, 'r', encoding='utf-8') as f:\n content = f.read()\n \n # Extract name from filename (remove extension)\n name = os.path.splitext(filename)[0]\n \n # Create a new style entry\n style_id = str(uuid.uuid4())\n new_style = {\n \"name\": name,\n \"category\": data.get('category', '导入提示词'), # Default category or user-provided\n \"content\": content,\n \"details\": {\n \"imported\": True,\n \"source\": file_path,\n \"import_date\": datetime.datetime.now().isoformat()\n }\n }\n \n styles_db[style_id] = new_style\n print(f\"Imported style from MD file: {style_id} - {name}\")\n save_styles() # 保存风格数据\n \n return jsonify({\n \"id\": style_id, \n \"name\": new_style[\"name\"],\n \"category\": new_style[\"category\"],\n \"content\": new_style[\"content\"],\n \"details\": new_style[\"details\"]\n }), 201\n except Exception as e:\n return jsonify({\"error\": f\"导入提示词文件时出错: {str(e)}\"}), 500\n\n@app.route('/api/styles/list-md-files', methods=['GET'])\ndef list_md_files():\n \"\"\"API endpoint to list all markdown files in the prompt folder.\"\"\"\n try:\n # Determine the prompt folder path\n if getattr(sys, 'frozen', False):\n prompt_folder = os.path.join(os.path.dirname(sys.executable), 'prompt')\n else:\n prompt_folder = os.path.join(os.path.dirname(__file__), 'prompt')\n \n # Get all .md files in the prompt folder\n md_files = [f for f in os.listdir(prompt_folder) if f.lower().endswith('.md')]\n \n return jsonify(md_files)\n except Exception as e:\n return jsonify({\"error\": f\"列出提示词文件时出错: {str(e)}\"}), 500\n\n@app.route('/api/styles/', methods=['GET'])\ndef get_style(style_id):\n \"\"\"API endpoint to get details of a specific style entry.\"\"\"\n style = styles_db.get(style_id)\n if style:\n return jsonify({\n \"id\": style_id,\n \"name\": style.get(\"name\", \"未命名风格\"),\n \"category\": style.get(\"category\", \"\"),\n \"content\": style.get(\"content\", \"\"),\n \"details\": style.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n@app.route('/api/styles/', methods=['PUT', 'PATCH'])\ndef update_style(style_id):\n \"\"\"API endpoint to update a style entry.\"\"\"\n if style_id not in styles_db:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n\n # Update fields provided in the request\n if 'name' in data:\n styles_db[style_id]['name'] = data['name']\n if 'category' in data:\n styles_db[style_id]['category'] = data['category']\n if 'content' in data:\n styles_db[style_id]['content'] = data['content']\n if 'details' in data:\n styles_db[style_id]['details'] = data['details']\n\n save_styles() # 保存风格数据\n return jsonify({\n \"message\": \"风格已更新\", \n \"id\": style_id\n })\n\n@app.route('/api/styles/', methods=['DELETE'])\ndef delete_style(style_id):\n \"\"\"API endpoint to delete a specific style entry.\"\"\"\n if style_id in styles_db:\n deleted_name = styles_db[style_id].get('name', '未命名风格')\n del styles_db[style_id]\n print(f\"Deleted style entry: {style_id} - {deleted_name}\")\n save_styles() # 保存风格数据\n return jsonify({\"message\": f\"风格 '{deleted_name}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n@app.route('/api/novels//styles', methods=['GET'])\ndef get_novel_styles(novel_id):\n \"\"\"API endpoint to get style entries associated with a specific novel.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"小说不存在\"}), 404\n \n # In a more advanced implementation, we might have a relationship table\n # For now, we'll return all style entries (could be filtered by novel later)\n return get_styles()\n\n@app.route('/api/games', methods=['GET'])\ndef get_games():\n \"\"\"API endpoint to list all games.\"\"\"\n game_list = [{\"id\": game_id, \"title\": data.get(\"title\", \"Untitled Game\")} for game_id, data in games_db.items()]\n return jsonify(game_list)\n\n@app.route('/api/games', methods=['POST'])\ndef create_game():\n \"\"\"API endpoint to create a new game.\"\"\"\n data = request.get_json()\n \n game_id = str(uuid.uuid4()) # Generate a unique ID\n new_game = {\n \"title\": data.get('title', 'New Game'),\n \"character\": data.get('character', ''),\n \"world\": data.get('world', ''),\n \"style\": data.get('style', ''),\n \"chat_history\": data.get('chat_history', []),\n \"created_at\": datetime.datetime.now().isoformat()\n }\n games_db[game_id] = new_game\n print(f\"Created game: {game_id} - {new_game['title']}\")\n save_games() # 保存游戏数据\n return jsonify({\"id\": game_id, \"title\": new_game[\"title\"]}), 201 # 201 Created status\n\n@app.route('/api/games/', methods=['GET'])\ndef get_game(game_id):\n \"\"\"API endpoint to get details of a specific game.\"\"\"\n game = games_db.get(game_id)\n if game:\n return jsonify({\n \"id\": game_id,\n \"title\": game.get(\"title\", \"Untitled\"),\n \"character\": game.get(\"character\", \"\"),\n \"world\": game.get(\"world\", \"\"),\n \"style\": game.get(\"style\", \"\"),\n \"chat_history\": game.get(\"chat_history\", []),\n \"created_at\": game.get(\"created_at\", \"\")\n })\n else:\n return jsonify({\"message\": \"Game not found\"}), 404\n\n@app.route('/api/games/', methods=['PUT', 'PATCH'])\ndef update_game(game_id):\n \"\"\"API endpoint to update a game's settings and history.\"\"\"\n if game_id not in games_db:\n return jsonify({\"message\": \"Game not found\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"message\": \"No update data provided\"}), 400\n\n # Update fields provided in the request\n if 'title' in data:\n games_db[game_id]['title'] = data['title']\n if 'character' in data:\n games_db[game_id]['character'] = data['character']\n if 'world' in data:\n games_db[game_id]['world'] = data['world']\n if 'style' in data:\n games_db[game_id]['style'] = data['style']\n if 'chat_history' in data:\n games_db[game_id]['chat_history'] = data['chat_history']\n \n save_games() # 保存游戏数据\n return jsonify({\"message\": \"Game updated successfully\"})\n\n@app.route('/api/games/', methods=['DELETE'])\ndef delete_game(game_id):\n \"\"\"API endpoint to delete a game.\"\"\"\n if game_id not in games_db:\n return jsonify({\"message\": \"Game not found\"}), 404\n \n deleted_title = games_db[game_id].get('title', 'Unknown')\n del games_db[game_id]\n save_games() # 保存游戏数据\n \n return jsonify({\"message\": f\"Game '{deleted_title}' deleted successfully\"})\n\n# --- Export Endpoints (Chat History) ---\n\n@app.route('/api/chat/export', methods=['POST'])\ndef export_chat_history():\n \"\"\"API endpoint to export chat history to a JSON file.\"\"\"\n data = request.get_json()\n if not data or 'chat_history' not in data or 'settings' not in data:\n return jsonify({\"error\": \"Missing required chat data\"}), 400\n\n chat_history = data['chat_history']\n settings = data['settings']\n \n try:\n # Generate a filename with timestamp\n timestamp = datetime.datetime.now().strftime(\"%Y-%m-%dT%H-%M-%S\")\n filename = f\"Chat_Export_{timestamp}.json\"\n filepath = os.path.join(EXPORT_DIR, filename)\n \n # Prepare export data\n export_data = {\n 'timestamp': timestamp,\n 'settings': settings,\n 'chat_history': chat_history\n }\n \n # Write to file\n with open(filepath, 'w', encoding='utf-8') as f:\n json.dump(export_data, f, ensure_ascii=False, indent=4)\n \n print(f\"Exported chat history to {filepath}\")\n # Return path relative to server script for confirmation\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\n \"message\": f\"聊天记录已成功导出至服务器: {relative_path}\", \n \"filepath\": relative_path\n })\n \n except Exception as e:\n print(f\"Error exporting chat history: {e}\")\n return jsonify({\"error\": f\"导出聊天记录时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/chat/import', methods=['POST'])\ndef import_chat_history():\n \"\"\"API endpoint to import chat history from a JSON file content.\"\"\"\n data = request.get_json()\n if not data or 'file_content' not in data:\n return jsonify({\"error\": \"Missing file content\"}), 400\n\n file_content = data['file_content']\n \n try:\n # Parse the JSON content\n imported_data = json.loads(strip_code_block(file_content))\n \n # Validate the imported data\n if 'chat_history' not in imported_data or 'settings' not in imported_data:\n return jsonify({\"error\": \"无效的聊天记录文件格式\"}), 400\n \n return jsonify({\n \"message\": \"聊天记录已成功导入\",\n \"chat_data\": imported_data\n })\n \n except json.JSONDecodeError as json_error:\n # More detailed error message for JSON parsing issues\n detail = str(json_error)\n print(f\"JSON parse error: {detail}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无效的JSON格式: {detail}\"}), 400\n except Exception as e:\n print(f\"Error importing chat history: {e}\")\n return jsonify({\"error\": f\"导入聊天记录时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/custom-apis', methods=['GET'])\ndef get_custom_apis():\n \"\"\"API endpoint to get the list of saved custom APIs.\"\"\"\n try:\n return jsonify(list(CUSTOM_API_CONFIGS.values()))\n except Exception as e:\n print(f\"Error fetching custom APIs: {e}\")\n return jsonify({\"error\": \"Could not retrieve custom API list.\"}), 500\n\n@app.route('/api/custom-apis', methods=['POST'])\ndef add_custom_api():\n \"\"\"API endpoint to add a new custom API configuration.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data or 'baseUrl' not in data or 'modelName' not in data:\n return jsonify({\"error\": \"Missing required fields\"}), 400\n \n api_id = str(uuid.uuid4())\n new_api = {\n \"id\": api_id,\n \"name\": data['name'],\n \"baseUrl\": data['baseUrl'],\n \"path\": data.get('path', ''),\n \"secret\": data.get('secret', ''),\n \"modelName\": data['modelName']\n }\n \n CUSTOM_API_CONFIGS[api_id] = new_api\n print(f\"Added custom API: {api_id} - {data['name']} - {data['baseUrl']}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify(new_api), 201\n\n@app.route('/api/custom-apis/', methods=['PUT'])\ndef update_custom_api(api_id):\n \"\"\"API endpoint to update an existing custom API configuration.\"\"\"\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"API configuration not found\"}), 404\n \n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"No update data provided\"}), 400\n \n # Update fields\n CUSTOM_API_CONFIGS[api_id].update({\n \"name\": data.get('name', CUSTOM_API_CONFIGS[api_id]['name']),\n \"baseUrl\": data.get('baseUrl', CUSTOM_API_CONFIGS[api_id]['baseUrl']),\n \"path\": data.get('path', CUSTOM_API_CONFIGS[api_id]['path']),\n \"secret\": data.get('secret', CUSTOM_API_CONFIGS[api_id]['secret']),\n \"modelName\": data.get('modelName', CUSTOM_API_CONFIGS[api_id]['modelName'])\n })\n \n print(f\"Updated custom API: {api_id}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify(CUSTOM_API_CONFIGS[api_id])\n\n@app.route('/api/custom-apis/', methods=['DELETE'])\ndef delete_custom_api(api_id):\n \"\"\"API endpoint to delete a custom API configuration.\"\"\"\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"API configuration not found\"}), 404\n \n deleted_name = CUSTOM_API_CONFIGS[api_id]['name']\n del CUSTOM_API_CONFIGS[api_id]\n print(f\"Deleted custom API: {api_id} - {deleted_name}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify({\"message\": f\"Custom API '{deleted_name}' deleted successfully\"})\n\n@app.route('/api/novel-types', methods=['GET'])\ndef get_novel_types():\n \"\"\"API endpoint to get novel types and subtypes from the 小说类型.txt file.\"\"\"\n try:\n novel_types = {}\n \n # Read the 小说类型.txt file\n novel_types_file = os.path.join(DATA_DIR, '小说类型.txt')\n if not os.path.exists(novel_types_file):\n logging.error(f\"小说类型文件不存在: {novel_types_file}\")\n return jsonify({\"error\": f\"小说类型文件不存在: {novel_types_file}\"}), 500\n with open(novel_types_file, 'r', encoding='utf-8') as file:\n lines = file.readlines()\n \n # Parse each line\n for line in lines:\n line = line.strip()\n if not line:\n continue\n \n # Extract main type and subtypes\n if '类型选项及展开子选项:' in line:\n parts = line.split('类型选项及展开子选项:')\n main_type = parts[0].strip()\n \n # Extract subtypes\n subtypes_text = parts[1].strip()\n if subtypes_text.endswith('。'):\n subtypes_text = subtypes_text[:-1] # Remove trailing period\n \n # Split by commas or Chinese commas\n subtypes = []\n for subtype in re.split('、|,|,', subtypes_text):\n clean_subtype = subtype.strip()\n if clean_subtype and clean_subtype not in ('包括', '等'):\n subtypes.append(clean_subtype)\n \n novel_types[main_type] = subtypes\n \n return jsonify(novel_types)\n except Exception as e:\n print(f\"Error loading novel types: {str(e)}\")\n return jsonify({\"error\": f\"Failed to load novel types: {str(e)}\"}), 500\n\n@app.route('/api/save-text-file', methods=['POST'])\ndef save_text_file():\n \"\"\"API endpoint to save text file to the exports directory.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data or 'filename' not in data:\n return jsonify({\"error\": \"Missing required fields: content or filename\"}), 400\n \n content = data['content']\n filename = data['filename']\n \n # Sanitize filename (allow alphanumeric, CJK characters, and some symbols)\n sanitized_filename = ''.join(c for c in filename if c.isalnum() or '\\u4e00' <= c <= '\\u9fff' or c in '-_. ')\n sanitized_filename = sanitized_filename or \"unnamed_file.txt\" # Fallback if filename is empty after sanitizing\n \n try:\n # Ensure the exports directory exists\n if not os.path.exists(EXPORT_DIR):\n os.makedirs(EXPORT_DIR)\n \n # Create the full path\n filepath = os.path.join(EXPORT_DIR, sanitized_filename)\n \n # Write the content to the file\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(content)\n \n # Return success response with relative path\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\n \"message\": \"文件已成功保存\",\n \"filepath\": relative_path\n })\n \n except Exception as e:\n error_message = f\"保存文件时出错: {str(e)}\"\n print(error_message)\n return jsonify({\"error\": error_message}), 500\n\n@app.route('/api/prompt-editor/export-template', methods=['POST'])\ndef export_prompt_template():\n \"\"\"API endpoint to save prompt editor content as a .md template file in the model directory.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data or 'filename' not in data:\n return jsonify({\"error\": \"Missing required fields: content or filename\"}), 400\n\n content = data['content']\n filename = data['filename']\n\n # Sanitize filename (similar to other export functions)\n # Allow alphanumeric, CJK characters, and some symbols. Ensure .md extension.\n sanitized_filename = ''.join(c for c in filename if c.isalnum() or '\\\\u4e00' <= c <= '\\\\u9fff' or c in '-_. ')\n if not sanitized_filename: # Fallback if filename is empty after sanitizing\n sanitized_filename = \"prompt_template\"\n \n if not sanitized_filename.lower().endswith('.md'):\n sanitized_filename += '.md'\n \n try:\n # Ensure the model directory exists (it should be created on app start, but double-check)\n if not os.path.exists(MODEL_DIR):\n os.makedirs(MODEL_DIR)\n \n filepath = os.path.join(MODEL_DIR, sanitized_filename)\n \n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(content)\n \n print(f\"Exported prompt template to {filepath}\")\n \n # Return path relative to server script for confirmation (optional, but consistent)\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError: # Handles cases where paths are on different drives (e.g., during PyInstaller build in temp)\n relative_path = filepath \n \n return jsonify({\n \"message\": f\"模板已成功导出至服务器 'model' 文件夹: {sanitized_filename}\",\n \"filepath\": relative_path # Sending back the relative path for consistency\n })\n \n except Exception as e:\n error_message = f\"导出模板文件时出错: {str(e)}\"\n print(error_message)\n return jsonify({\"error\": error_message}), 500\n\n# novels_db 示例 (Add the chapters field to the novel structure)\nnovels_db = {\n \"novel_id_1\": {\n \"title\": \"我的第一部小说\",\n \"content\": \"...\", # 可能废弃或作为默认/未分章节内容\n \"characters\": \"...\",\n \"knowledge\": \"...\",\n \"style_prompt\": \"...\",\n \"character_tags\": [],\n \"glossary_tags\": [],\n \"style_tags\": [],\n \"chapters\": [] # Add empty chapters list to store chapter data\n }\n}\n\n# Chapter Management API Endpoints\n@app.route('/api/novels//chapters', methods=['GET'])\ndef get_novel_chapters(novel_id):\n \"\"\"Get all chapters for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # If no chapters field exists yet, initialize it\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n \n # Return chapters without content to reduce payload size\n chapters = []\n for chapter in novels_db[novel_id][\"chapters\"]:\n chapter_copy = {\n \"id\": chapter[\"id\"],\n \"title\": chapter[\"title\"],\n \"order\": chapter.get(\"order\", 0),\n \"created_at\": chapter.get(\"created_at\", \"\")\n }\n chapters.append(chapter_copy)\n \n return jsonify(chapters)\n\n@app.route('/api/novels//chapters', methods=['POST'])\ndef create_novel_chapter(novel_id):\n \"\"\"Create a new chapter for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Get request data\n data = request.json or {}\n title = data.get(\"title\", \"新章节\")\n \n # Initialize chapters list if it doesn't exist\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n \n # Create a new chapter with UUID\n now = datetime.datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n new_chapter = {\n \"id\": str(uuid.uuid4()),\n \"title\": title,\n \"content\": \"\",\n \"order\": len(novels_db[novel_id][\"chapters\"]), # Add at the end\n \"created_at\": now\n }\n \n # Add the new chapter to the novel's chapters\n novels_db[novel_id][\"chapters\"].append(new_chapter)\n \n # Save to disk\n save_novels()\n \n # Return the new chapter (without content)\n chapter_copy = {key: val for key, val in new_chapter.items() if key != \"content\"}\n return jsonify(chapter_copy), 201\n\n@app.route('/api/novels//chapters/', methods=['GET'])\ndef get_novel_chapter(novel_id, chapter_id):\n \"\"\"Get a specific chapter with its content\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Find the chapter by ID\n for chapter in novels_db[novel_id][\"chapters\"]:\n if chapter[\"id\"] == chapter_id:\n return jsonify(chapter)\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/', methods=['PUT'])\ndef update_novel_chapter(novel_id, chapter_id):\n \"\"\"Update a specific chapter\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Get request data\n data = request.json or {}\n \n # Find and update the chapter\n for i, chapter in enumerate(novels_db[novel_id][\"chapters\"]):\n if chapter[\"id\"] == chapter_id:\n # Update fields that were provided\n if \"title\" in data:\n novels_db[novel_id][\"chapters\"][i][\"title\"] = data[\"title\"]\n if \"content\" in data:\n novels_db[novel_id][\"chapters\"][i][\"content\"] = data[\"content\"]\n if \"order\" in data:\n novels_db[novel_id][\"chapters\"][i][\"order\"] = data[\"order\"]\n \n # Save changes\n save_novels()\n \n # Return the updated chapter\n return jsonify(novels_db[novel_id][\"chapters\"][i])\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/', methods=['DELETE'])\ndef delete_novel_chapter(novel_id, chapter_id):\n \"\"\"Delete a specific chapter\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Find and remove the chapter\n for i, chapter in enumerate(novels_db[novel_id][\"chapters\"]):\n if chapter[\"id\"] == chapter_id:\n # Remove the chapter\n removed_chapter = novels_db[novel_id][\"chapters\"].pop(i)\n \n # Reorder remaining chapters if needed\n for j, ch in enumerate(novels_db[novel_id][\"chapters\"]):\n ch[\"order\"] = j\n \n # Save changes\n save_novels()\n \n return jsonify({\"message\": f\"Chapter '{removed_chapter['title']}' deleted successfully\"})\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/reorder', methods=['PUT'])\ndef reorder_novel_chapters(novel_id):\n \"\"\"Reorder chapters for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n return jsonify({\"message\": \"No chapters to reorder\"}), 200\n \n # Get request data - expecting an array of chapter IDs in the new order\n data = request.json or []\n \n if not isinstance(data, list):\n return jsonify({\"error\": \"Invalid data format. Expected array of chapter IDs with order\"}), 400\n \n # Create a map of chapter IDs to their current objects\n chapter_map = {chapter[\"id\"]: chapter for chapter in novels_db[novel_id][\"chapters\"]}\n \n # Create a new ordered list of chapters\n new_chapters = []\n \n # Process each item in the request\n for i, item in enumerate(data):\n if isinstance(item, dict) and \"id\" in item:\n chapter_id = item[\"id\"]\n if chapter_id in chapter_map:\n # Get the original chapter and update its order\n chapter = chapter_map[chapter_id].copy()\n chapter[\"order\"] = i\n new_chapters.append(chapter)\n \n # If any chapters were missed in the request, append them at the end\n missed_chapters = [chapter for chapter_id, chapter in chapter_map.items() \n if chapter_id not in [item[\"id\"] for item in data if isinstance(item, dict) and \"id\" in item]]\n \n for i, chapter in enumerate(missed_chapters):\n chapter[\"order\"] = len(new_chapters) + i\n new_chapters.append(chapter)\n \n # Replace the chapters list\n novels_db[novel_id][\"chapters\"] = new_chapters\n \n # Save changes\n save_novels()\n \n return jsonify({\"message\": \"Chapters reordered successfully\"})\n\n@app.route('/api/character-relationship-graph', methods=['POST'])\ndef generate_character_relationship_graph():\n \"\"\"生成角色关系图谱,支持自定义API模型\"\"\"\n try:\n req = request.json or {}\n content = req.get('content', '')\n model = req.get('model', '')\n if not content:\n return jsonify({'error': '没有提供内容'}), 400\n if not model:\n return jsonify({'error': '未选择模型'}), 400\n\n # 优化后的prompt,更简洁且强制输出格式\n prompt = (\n \"分析文本中的人物关系,输出JSON格式:\\n\"\n \"{\\\"nodes\\\":[{\\\"id\\\":\\\"人物名\\\",\\\"name\\\":\\\"人物名\\\"}],\\\"links\\\":[{\\\"source\\\":\\\"人物1\\\",\\\"target\\\":\\\"人物2\\\",\\\"relation\\\":\\\"关系\\\"}]}\\n\"\n \"要求:\\n\"\n \"1. nodes包含所有人物,只需id和name字段\\n\"\n \"2. links包含所有关系,必须有relation字段\\n\"\n \"3. relation必须是简短中文词语\\n\"\n \"4. 只输出JSON,不要其他内容\\n\"\n f\"文本:{content}\"\n )\n\n # 自定义API模型\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({'error': '自定义API配置不存在'}), 404\n\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config.get('path', '')\n model_name = api_config['modelName']\n secret = api_config.get('secret', '')\n\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n api_url = f\"{clean_base_url}/{clean_path}\" if clean_path else clean_base_url\n\n headers = {\"Content-Type\": \"application/json\"}\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n payload = {\n \"model\": model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000, # 增加token限制\n \"temperature\": 0.3 # 降低随机性\n }\n\n try:\n # 记录请求信息\n logging.info(f\"发送请求到自定义API: {api_url}\")\n logging.info(f\"请求头: {headers}\")\n logging.info(f\"请求体: {json.dumps(payload, ensure_ascii=False)}\")\n\n resp = requests.post(api_url, headers=headers, json=payload, timeout=60)\n \n # 记录响应信息\n logging.info(f\"API响应状态码: {resp.status_code}\")\n logging.info(f\"API响应头: {dict(resp.headers)}\")\n logging.info(f\"API响应内容: {resp.text[:1000]}...\") # 只记录前1000个字符\n\n if resp.status_code != 200:\n error_msg = f\"自定义API分析失败,状态码: {resp.status_code}\"\n try:\n error_detail = resp.json().get('error', {}).get('message', resp.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {resp.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = resp.json()\n logging.info(f\"API响应JSON解析成功: {json.dumps(result, ensure_ascii=False)[:200]}...\")\n\n # 兼容OpenAI格式和常见格式\n if 'choices' in result and result['choices']:\n content = result['choices'][0].get('message',{}).get('content','')\n if not content:\n logging.error(\"API响应中没有content字段\")\n return jsonify({'error': 'API响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n logging.info(f\"处理后的content: {content[:200]}...\")\n \n # 检查JSON是否完整\n if not content.strip().endswith('}'):\n logging.error(f\"API返回的JSON不完整: {content}\")\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n try:\n relationship_data = json.loads(content)\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {content}\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n else:\n relationship_data = result\n\n # 验证返回的数据结构\n if not isinstance(relationship_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in relationship_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in relationship_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(relationship_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(relationship_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n\n return jsonify(relationship_data)\n\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {resp.text[:1000]}...\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"处理API响应时出错: {str(e)}, 原始内容: {resp.text[:1000]}...\")\n return jsonify({'error': f'处理API响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n logging.error(f\"请求自定义API时出错: {str(e)}\")\n return jsonify({'error': f'请求自定义API时出错: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"自定义API调用出错: {str(e)}\")\n return jsonify({'error': f'自定义API调用出错: {str(e)}'}), 500\n\n # OpenRouter模型\n else:\n if not OPENROUTER_API_KEY:\n return jsonify({'error': '请先设置OpenRouter API密钥'}), 401\n\n try:\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n },\n json={\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000, # 增加token限制\n \"temperature\": 0.3 # 降低随机性\n }\n )\n\n # 记录响应信息\n logging.info(f\"OpenRouter响应状态码: {response.status_code}\")\n logging.info(f\"OpenRouter响应内容: {response.text[:1000]}...\")\n\n if response.status_code != 200:\n error_msg = f\"AI分析失败,状态码: {response.status_code}\"\n try:\n error_detail = response.json().get('error', {}).get('message', response.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {response.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = response.json()\n content = result['choices'][0]['message']['content']\n if not content:\n logging.error(\"OpenRouter响应中没有content字段\")\n return jsonify({'error': 'OpenRouter响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n logging.info(f\"处理后的content: {content[:200]}...\")\n \n # 检查JSON是否完整\n if not content.strip().endswith('}'):\n logging.error(f\"API返回的JSON不完整: {content}\")\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n relationship_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(relationship_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in relationship_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in relationship_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(relationship_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(relationship_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(relationship_data)\n\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {content}\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"处理OpenRouter响应时出错: {str(e)}, 原始内容: {response.text[:1000]}...\")\n return jsonify({'error': f'处理OpenRouter响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n logging.error(f\"请求OpenRouter时出错: {str(e)}\")\n return jsonify({'error': f'请求OpenRouter时出错: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"OpenRouter调用出错: {str(e)}\")\n return jsonify({'error': f'OpenRouter调用出错: {str(e)}'}), 500\n\n except Exception as e:\n logging.error(f\"生成角色关系图谱时出错: {str(e)}\")\n return jsonify({'error': str(e)}), 500\n\ndef strip_code_block(text):\n \"\"\"\n 去除AI返回内容中的```json、```text等代码块包裹。\n \"\"\"\n import re\n # 匹配```json ... ```或```text ... ```或``` ... ```\n pattern = r\"^```(?:json|text)?\\s*([\\s\\S]*?)\\s*```$\"\n match = re.match(pattern, text.strip(), re.IGNORECASE)\n if match:\n return match.group(1).strip()\n return text.strip()\n\n@app.route('/api/knowledge-graph', methods=['POST'])\ndef generate_knowledge_graph():\n \"\"\"生成知识图谱,支持自定义API模型\"\"\"\n try:\n req = request.json or {}\n content = req.get('content', '')\n model = req.get('model', '')\n if not content:\n return jsonify({'error': '没有提供内容'}), 400\n if not model:\n return jsonify({'error': '未选择模型'}), 400\n\n # 优化后的prompt,更简洁且强制输出格式\n prompt = (\n \"分析文本中的知识点和关系,输出JSON格式:\\n\"\n \"{\\\"nodes\\\":[{\\\"id\\\":\\\"知识点ID\\\",\\\"name\\\":\\\"知识点名称\\\",\\\"category\\\":\\\"分类\\\",\\\"tags\\\":[\\\"标签1\\\",\\\"标签2\\\"]}],\"\n \"\\\"links\\\":[{\\\"source\\\":\\\"知识点1\\\",\\\"target\\\":\\\"知识点2\\\",\\\"relation\\\":\\\"关系\\\",\\\"type\\\":\\\"关系类型\\\"}]}\\n\"\n \"要求:\\n\"\n \"1. nodes包含所有知识点,必须有id、name、category和tags字段\\n\"\n \"2. category必须是以下之一:人物、地点、物品、概念、事件、规则\\n\"\n \"3. tags是相关标签数组,至少包含一个标签\\n\"\n \"4. links包含所有关系,必须有source、target、relation和type字段\\n\"\n \"5. type必须是以下之一:包含、属于、导致、影响、引用\\n\"\n \"6. relation必须是简短中文词语\\n\"\n \"7. 只输出JSON,不要其他内容\\n\"\n f\"文本:{content}\"\n )\n\n # 自定义API模型\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({'error': '自定义API配置不存在'}), 404\n\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config.get('path', '')\n model_name = api_config['modelName']\n secret = api_config.get('secret', '')\n\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n api_url = f\"{clean_base_url}/{clean_path}\" if clean_path else clean_base_url\n\n headers = {\"Content-Type\": \"application/json\"}\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n payload = {\n \"model\": model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000,\n \"temperature\": 0.3\n }\n\n try:\n resp = requests.post(api_url, headers=headers, json=payload, timeout=60)\n \n if resp.status_code != 200:\n error_msg = f\"自定义API分析失败,状态码: {resp.status_code}\"\n try:\n error_detail = resp.json().get('error', {}).get('message', resp.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {resp.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = resp.json()\n content = result['choices'][0]['message']['content']\n if not content:\n return jsonify({'error': 'API响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n \n if not content.strip().endswith('}'):\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n knowledge_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(knowledge_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in knowledge_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in knowledge_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(knowledge_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(knowledge_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(knowledge_data)\n\n except json.JSONDecodeError as e:\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'处理API响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n return jsonify({'error': f'请求自定义API时出错: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'自定义API调用出错: {str(e)}'}), 500\n\n # OpenRouter模型\n else:\n if not OPENROUTER_API_KEY:\n return jsonify({'error': '请先设置OpenRouter API密钥'}), 401\n\n try:\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n },\n json={\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000,\n \"temperature\": 0.3\n }\n )\n\n if response.status_code != 200:\n error_msg = f\"AI分析失败,状态码: {response.status_code}\"\n try:\n error_detail = response.json().get('error', {}).get('message', response.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {response.text[:200]}...\"\n return jsonify({'error': error_msg}), 500\n\n try:\n result = response.json()\n content = result['choices'][0]['message']['content']\n if not content:\n return jsonify({'error': 'OpenRouter响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n \n if not content.strip().endswith('}'):\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n knowledge_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(knowledge_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in knowledge_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in knowledge_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(knowledge_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(knowledge_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(knowledge_data)\n\n except json.JSONDecodeError as e:\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'处理OpenRouter响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n return jsonify({'error': f'请求OpenRouter时出错: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'OpenRouter调用出错: {str(e)}'}), 500\n\n except Exception as e:\n return jsonify({'error': str(e)}), 500\n\nif __name__ == '__main__':\n # 确保静态文件夹配置正确\n import webbrowser\n from threading import Timer\n \n # 设置端口\n port = 5000\n \n # 打包后的环境自动启用生产模式\n debug_mode = not getattr(sys, 'frozen', False)\n \n # 添加标志变量防止多次打开浏览器\n browser_opened = False\n \n # 定义一个函数,在应用启动后打开浏览器\n def open_browser():\n global browser_opened\n # 仅在browser_opened为False时打开浏览器\n if not browser_opened:\n webbrowser.open(f'http://127.0.0.1:{port}/')\n browser_opened = True\n print(\"笔墨星河已成功启动!浏览器窗口即将打开。\")\n print(f\"如果没有自动打开,请手动访问: http://127.0.0.1:{port}/\")\n print(\"笔墨星河,本软件免费,请合法合规使用本软件,请勿用作非法用途。软件作者:L.H\") \n # 在新线程中启动浏览器(延迟1.5秒,确保Flask服务器已启动)\n # 增加延迟时间,确保服务器完全启动\n Timer(1.5, open_browser).start()\n \n print(\"正在启动笔墨星河应用...\")\n # 在启动应用前加载数据\n load_data()\n # 启动Flask应用,禁用Flask的自动重载器,这可能导致浏览器被打开两次\n app.run(debug=debug_mode, port=port, use_reloader=False)"}, "5bd6abb5aa7c994c2f9a1843bee0de3aac51c8fb": {"t": 1785773593.6274962, "n": 101696, "h": "ece1077810bb8d47", "c": "import requests\nimport os\nimport uuid\nimport sys\nfrom flask import Flask, request, jsonify, render_template, Response, stream_with_context\nimport re\nimport tempfile\nimport json\nimport logging\nfrom docx import Document\nfrom docx.shared import Inches\nimport datetime\nimport io\nimport warnings # Added for API key warning\nimport re\nimport tempfile\nimport base64\nimport networkx as nx\nimport matplotlib.pyplot as plt\nfrom io import BytesIO\nimport mimetypes\n\n# 添加对PyInstaller打包的支持\ndef resource_path(relative_path):\n \"\"\"获取资源的绝对路径,支持开发环境和PyInstaller打包后的环境\"\"\"\n try:\n # PyInstaller创建临时文件夹并将路径存储在_MEIPASS中\n base_path = sys._MEIPASS\n except Exception:\n base_path = os.path.abspath(\".\")\n \n return os.path.join(base_path, relative_path)\n\napp = Flask(__name__, \n template_folder=resource_path('templates'),\n static_folder=resource_path('static'))\n\n@app.after_request\ndef set_js_mime_type(response):\n if request.path.endswith('.js'):\n response.headers['Content-Type'] = 'application/javascript'\n return response\n\n# 设置日志\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\n\n# Configuration\nOPENROUTER_API_BASE = \"https://openrouter.ai/api/v1\" # OpenRouter Base URL\nOPENROUTER_API_KEY = None # Will be set via API endpoint\nCUSTOM_API_CONFIGS = {} # Will store custom API configurations in memory\n\n# 确定是否在打包环境中运行\nif getattr(sys, 'frozen', False):\n # 如果是打包后的EXE,使用应用程序所在目录\n application_path = os.path.dirname(sys.executable)\n EXPORT_DIR = os.path.join(application_path, 'exports')\n DATA_DIR = os.path.join(application_path, 'data') # 数据文件目录\n MODEL_DIR = os.path.join(application_path, 'model') # 新增:模型模板文件夹\nelse:\n # 正常开发环境\n EXPORT_DIR = os.path.join(os.path.dirname(__file__), 'exports')\n DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') # 数据文件目录\n MODEL_DIR = os.path.join(os.path.dirname(__file__), 'model') # 新增:模型模板文件夹\n\n# 数据文件路径\nNOVELS_FILE = os.path.join(DATA_DIR, 'novels.json')\nCHARACTERS_FILE = os.path.join(DATA_DIR, 'characters.json')\nGLOSSARY_FILE = os.path.join(DATA_DIR, 'glossary.json')\nSTYLES_FILE = os.path.join(DATA_DIR, 'styles.json')\nGAMES_FILE = os.path.join(DATA_DIR, 'games.json')\nAPI_CONFIGS_FILE = os.path.join(DATA_DIR, 'api_configs.json') # 自定义API配置文件\nAPI_KEY_FILE = os.path.join(DATA_DIR, 'api_key.json') # OpenRouter API密钥文件\n\n# Simple in-memory storage for novels (replace with a database for persistence)\nnovels_db = {}\n# Simple in-memory storage for character library\ncharacters_db = {}\n# Simple in-memory storage for glossary entries\nglossary_db = {}\n# Simple in-memory storage for style library\nstyles_db = {}\n# Simple in-memory storage for games\ngames_db = {}\n\n# Ensure export and data directories exist\nif not os.path.exists(EXPORT_DIR):\n os.makedirs(EXPORT_DIR)\nif not os.path.exists(DATA_DIR):\n os.makedirs(DATA_DIR)\nif not os.path.exists(MODEL_DIR): # 新增:确保模型文件夹存在\n os.makedirs(MODEL_DIR)\n\n# 加载持久化数据\ndef load_data():\n \"\"\"从JSON文件加载数据到内存\"\"\"\n global novels_db, characters_db, glossary_db, styles_db, games_db, CUSTOM_API_CONFIGS, OPENROUTER_API_KEY\n \n logging.info(\"尝试加载持久化数据...\")\n logging.info(f\"小说数据文件路径: {NOVELS_FILE}\")\n\n # 确保所有数据目录存在\n for directory in [DATA_DIR, EXPORT_DIR, MODEL_DIR]:\n if not os.path.exists(directory):\n os.makedirs(directory)\n logging.info(f\"创建目录: {directory}\")\n\n # 确保所有数据文件存在\n data_files = {\n NOVELS_FILE: {},\n CHARACTERS_FILE: {},\n GLOSSARY_FILE: {},\n STYLES_FILE: {},\n GAMES_FILE: {},\n API_CONFIGS_FILE: {},\n API_KEY_FILE: {\"api_key\": None}\n }\n\n for file_path, default_content in data_files.items():\n if not os.path.exists(file_path):\n try:\n with open(file_path, 'w', encoding='utf-8') as f:\n json.dump(default_content, f, ensure_ascii=False, indent=2)\n logging.info(f\"创建数据文件: {file_path}\")\n except Exception as e:\n logging.error(f\"创建数据文件失败 {file_path}: {e}\")\n\n # 加载小说数据\n if os.path.exists(NOVELS_FILE):\n logging.info(\"小说数据文件存在,尝试加载...\")\n try:\n with open(NOVELS_FILE, 'r', encoding='utf-8') as f:\n novels_db = json.load(f)\n logging.info(f\"成功加载了 {len(novels_db)} 部小说。\")\n # 添加日志,打印加载后的小说ID列表和总数\n logging.info(f\"加载后的小说ID列表 (前5个): {list(novels_db.keys())[:5]}\")\n logging.info(f\"加载后的小说总数: {len(novels_db)}\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载小说数据出错 (JSON解析错误): {e}\")\n novels_db = {}\n except Exception as e:\n logging.error(f\"加载小说数据出错 (其他错误): {e}\")\n novels_db = {}\n else:\n logging.info(\"小说数据文件不存在,初始化为空数据库。\")\n novels_db = {}\n \n \n # 加载角色数据\n if os.path.exists(CHARACTERS_FILE):\n try:\n with open(CHARACTERS_FILE, 'r', encoding='utf-8') as f:\n characters_db = json.load(f)\n logging.info(f\"成功加载了 {len(characters_db)} 个角色。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载角色数据出错 (JSON解析错误): {e}\")\n characters_db = {}\n except Exception as e:\n logging.error(f\"加载角色数据出错 (其他错误): {e}\")\n characters_db = {}\n else:\n logging.info(\"角色数据文件不存在,初始化为空数据库。\")\n characters_db = {}\n \n # 加载词条数据\n if os.path.exists(GLOSSARY_FILE):\n try:\n with open(GLOSSARY_FILE, 'r', encoding='utf-8') as f:\n glossary_db = json.load(f)\n logging.info(f\"成功加载了 {len(glossary_db)} 个词条。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载词条数据出错 (JSON解析错误): {e}\")\n glossary_db = {}\n except Exception as e:\n logging.error(f\"加载词条数据出错 (其他错误): {e}\")\n glossary_db = {}\n else:\n logging.info(\"词条数据文件不存在,初始化为空数据库。\")\n glossary_db = {}\n \n # 加载风格数据\n if os.path.exists(STYLES_FILE):\n try:\n with open(STYLES_FILE, 'r', encoding='utf-8') as f:\n styles_db = json.load(f)\n logging.info(f\"成功加载了 {len(styles_db)} 个风格词条。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载风格数据出错 (JSON解析错误): {e}\")\n styles_db = {}\n except Exception as e:\n logging.error(f\"加载风格数据出错 (其他错误): {e}\")\n styles_db = {}\n else:\n logging.info(\"风格数据文件不存在,初始化为空数据库。\")\n styles_db = {}\n \n # 加载游戏数据\n if os.path.exists(GAMES_FILE):\n try:\n with open(GAMES_FILE, 'r', encoding='utf-8') as f:\n games_db = json.load(f)\n logging.info(f\"成功加载了 {len(games_db)} 个游戏记录。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载游戏数据出错 (JSON解析错误): {e}\")\n games_db = {}\n except Exception as e:\n logging.error(f\"加载游戏数据出错 (其他错误): {e}\")\n games_db = {}\n else:\n logging.info(\"游戏数据文件不存在,初始化为空数据库。\")\n games_db = {}\n \n # 加载自定义API配置\n if os.path.exists(API_CONFIGS_FILE):\n try:\n with open(API_CONFIGS_FILE, 'r', encoding='utf-8') as f:\n CUSTOM_API_CONFIGS = json.load(f)\n logging.info(f\"成功加载了 {len(CUSTOM_API_CONFIGS)} 个自定义API配置。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载自定义API配置出错 (JSON解析错误): {e}\")\n CUSTOM_API_CONFIGS = {}\n except Exception as e:\n logging.error(f\"加载自定义API配置出错 (其他错误): {e}\")\n CUSTOM_API_CONFIGS = {}\n else:\n logging.info(\"自定义API配置文件不存在,初始化为空配置。\")\n CUSTOM_API_CONFIGS = {}\n \n # 加载OpenRouter API密钥\n if os.path.exists(API_KEY_FILE):\n try:\n with open(API_KEY_FILE, 'r', encoding='utf-8') as f:\n api_key_data = json.load(f)\n OPENROUTER_API_KEY = api_key_data.get(\"api_key\")\n if OPENROUTER_API_KEY:\n logging.info(\"成功加载了API密钥。\")\n except json.JSONDecodeError as e:\n logging.error(f\"加载API密钥出错 (JSON解析错误): {e}\")\n OPENROUTER_API_KEY = None\n except Exception as e:\n logging.error(f\"加载API密钥出错 (其他错误): {e}\")\n OPENROUTER_API_KEY = None\n else:\n print(\"API密钥文件不存在或密钥未设置。\")\n OPENROUTER_API_KEY = None\n\n# 保存数据到文件\ndef save_novels():\n \"\"\"保存小说数据到文件\"\"\"\n logging.info(\"尝试保存小说数据...\")\n logging.info(f\"小说数据文件路径: {NOVELS_FILE}\")\n try:\n # 使用临时文件进行原子性写入\n with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False, dir=os.path.dirname(NOVELS_FILE)) as tmp_file:\n json.dump(novels_db, tmp_file, ensure_ascii=False, indent=2)\n # 原子性替换原文件\n os.replace(tmp_file.name, NOVELS_FILE)\n logging.info(f\"成功保存了 {len(novels_db)} 部小说。\")\n return True\n except Exception as e:\n logging.error(f\"保存小说数据出错: {e}\")\n # 如果发生错误,尝试清理临时文件\n if 'tmp_file' in locals() and os.path.exists(tmp_file.name):\n os.remove(tmp_file.name)\n return False\n\ndef save_characters():\n \"\"\"保存角色数据到文件\"\"\"\n try:\n with open(CHARACTERS_FILE, 'w', encoding='utf-8') as f:\n json.dump(characters_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(characters_db)} 个角色。\")\n return True\n except Exception as e:\n print(f\"保存角色数据出错: {e}\")\n return False\n\ndef save_glossary():\n \"\"\"保存词条数据到文件\"\"\"\n try:\n with open(GLOSSARY_FILE, 'w', encoding='utf-8') as f:\n json.dump(glossary_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(glossary_db)} 个词条。\")\n return True\n except Exception as e:\n print(f\"保存词条数据出错: {e}\")\n return False\n\ndef save_styles():\n \"\"\"保存风格数据到文件\"\"\"\n try:\n with open(STYLES_FILE, 'w', encoding='utf-8') as f:\n json.dump(styles_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(styles_db)} 个风格词条。\")\n return True\n except Exception as e:\n print(f\"保存风格数据出错: {e}\")\n return False\n\ndef save_games():\n \"\"\"保存游戏数据到文件\"\"\"\n try:\n with open(GAMES_FILE, 'w', encoding='utf-8') as f:\n json.dump(games_db, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(games_db)} 个游戏记录。\")\n return True\n except Exception as e:\n print(f\"保存游戏数据出错: {e}\")\n return False\n\ndef save_api_configs():\n \"\"\"保存自定义API配置到文件\"\"\"\n try:\n with open(API_CONFIGS_FILE, 'w', encoding='utf-8') as f:\n json.dump(CUSTOM_API_CONFIGS, f, ensure_ascii=False, indent=2)\n print(f\"成功保存了 {len(CUSTOM_API_CONFIGS)} 个自定义API配置。\")\n return True\n except Exception as e:\n print(f\"保存自定义API配置出错: {e}\")\n return False\n\ndef save_api_key():\n \"\"\"保存OpenRouter API密钥到文件\"\"\"\n try:\n with open(API_KEY_FILE, 'w', encoding='utf-8') as f:\n json.dump({\"api_key\": OPENROUTER_API_KEY}, f, ensure_ascii=False, indent=2)\n print(\"成功保存了API密钥。\")\n return True\n except Exception as e:\n print(f\"保存API密钥出错: {e}\")\n return False\n\n# 在应用启动时加载数据\nload_data()\n\n# --- Helper Functions ---\n\n# Renamed function and updated to return specified OpenRouter models\ndef get_available_models():\n \"\"\"Returns a list of available models (currently hardcoded for OpenRouter).\"\"\"\n # In the future, this could potentially fetch from OpenRouter, but for now,\n # we list the specified free models.\n return [\n {\"id\": \"deepseek/deepseek-chat-v3-0324:free\", \"name\": \"Deepseek/deepseek-chat-v3-0324:free (Free)\"},\n {\"id\": \"qwen/qwen3-235b-a22b:free\", \"name\": \"Qwen/qwen3-235b-a22b:free (Free)\"},\n {\"id\": \"google/gemma-3-27b-it:free\", \"name\": \"Google Gemma 3 27b IT (Free)\"},\n {\"id\": \"qwen/qwen2.5-vl-72b-instruct:free\", \"name\": \"qwen/qwen2.5-vl-72b-instruct:free\"},\n {\"id\": \"meta-llama/llama-4-scout:free\", \"name\": \"Meta-llama/llama-4-scout:free (Free)\"},\n {\"id\": \"google/gemini-2.0-flash-exp:free\", \"name\": \"Google/gemini-2.0-flash-exp:free (Free)\"},\n {\"id\": \"deepseek/deepseek-r1:free\", \"name\": \"DeepSeek Coder R1 (Free)\"}\n ]\n # --- Removed Ollama fetching logic ---\n # try:\n # response = requests.get(f\"{OLLAMA_BASE_URL}/api/tags\")\n # response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)\n # models_data = response.json()\n # # Extract just the model names\n # model_names = [model['name'] for model in models_data.get('models', [])]\n # return model_names\n # except requests.exceptions.RequestException as e:\n # print(f\"Error connecting to Ollama: {e}\")\n # return None # Indicate failure\n # except json.JSONDecodeError:\n # print(f\"Error decoding JSON response from Ollama: {response.text}\")\n # return None\n # except Exception as e:\n # print(f\"An unexpected error occurred while fetching models: {e}\")\n # return None\n\n# --- API Endpoints ---\n\n@app.route('/')\ndef index():\n \"\"\"Serves the main HTML page.\"\"\"\n return render_template('index.html')\n\n@app.route('/api/models', methods=['GET'])\ndef list_models():\n \"\"\"API endpoint to get the list of available models.\"\"\"\n # Get OpenRouter models\n openrouter_models = get_available_models()\n \n # Get custom API models from configurations\n custom_models = []\n for api_id, config in CUSTOM_API_CONFIGS.items():\n custom_models.append({\n \"id\": f\"custom-{api_id}\", \n \"name\": f\"{config['name']} - {config['modelName']}\"\n })\n \n # Combine both lists\n all_models = openrouter_models + custom_models\n \n if all_models:\n return jsonify(all_models)\n else:\n return jsonify({\"error\": \"Could not retrieve model list.\"}), 500\n\n@app.route('/api/novels', methods=['GET'])\ndef get_novels():\n \"\"\"API endpoint to list all novels.\"\"\"\n # Return a list of novels with id and title\n novel_list = [{\"id\": novel_id, \"title\": data.get(\"title\", \"Untitled\")} for novel_id, data in novels_db.items()]\n return jsonify(novel_list)\n\n@app.route('/api/novels', methods=['POST'])\ndef create_novel():\n \"\"\"API endpoint to create a new novel.\"\"\"\n data = request.get_json()\n if not data or 'title' not in data:\n return jsonify({\"message\": \"Title is required\"}), 400\n\n novel_id = str(uuid.uuid4()) # Generate a unique ID\n new_novel = {\n \"title\": data['title'],\n \"content\": data.get('content', ''), # Initialize with empty content or provided content\n \"characters\": \"\", # Initialize text field\n \"knowledge\": \"\",\n \"style_prompt\": \"\", # Initialize text field\n \"character_tags\": [], # Initialize tag arrays\n \"glossary_tags\": [],\n \"style_tags\": []\n # Add other metadata later (e.g., settings, characters)\n }\n novels_db[novel_id] = new_novel\n logging.info(f\"Created novel in memory: {novel_id} - {data['title']}\")\n \n if save_novels(): # 保存小说数据并检查结果\n logging.info(f\"Successfully saved novel {novel_id} to file.\")\n return jsonify({\"id\": novel_id, \"title\": new_novel[\"title\"]}), 201 # 201 Created status\n else:\n logging.error(f\"Failed to save novel {novel_id} to file.\")\n # Decide how to handle save failure - maybe return a 500 error?\n # For now, we'll still return 201 as the novel is in memory, but logging indicates the issue.\n # A more robust solution might require rolling back the in-memory addition or returning an error.\n # Given the current structure, logging the error is the most direct fix based on the proposal.\n return jsonify({\"id\": novel_id, \"title\": new_novel[\"title\"], \"warning\": \"Failed to save novel to file\"}), 201 # Still return 201 but add a warning\n\n@app.route('/api/novels/', methods=['GET'])\ndef get_novel(novel_id):\n \"\"\"API endpoint to get details of a specific novel.\"\"\"\n novel = novels_db.get(novel_id)\n if novel:\n return jsonify({\n \"id\": novel_id,\n \"title\": novel.get(\"title\", \"Untitled\"),\n \"content\": novel.get(\"content\", \"\"),\n \"characters\": novel.get(\"characters\", \"\"), # Return text field\n \"knowledge\": novel.get(\"knowledge\", \"\"),\n \"style_prompt\": novel.get(\"style_prompt\", \"\"), # Return text field\n \"character_tags\": novel.get(\"character_tags\", []), # Return tag arrays\n \"glossary_tags\": novel.get(\"glossary_tags\", []),\n \"style_tags\": novel.get(\"style_tags\", [])\n # Add other fields as needed\n })\n else:\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n@app.route('/api/novels/', methods=['PUT', 'PATCH'])\ndef update_novel(novel_id):\n \"\"\"API endpoint to update a novel's content (or other fields).\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"message\": \"No update data provided\"}), 400\n\n # Update fields provided in the request\n if 'title' in data:\n novels_db[novel_id]['title'] = data['title']\n if 'content' in data:\n novels_db[novel_id]['content'] = data['content']\n # Add updates for metadata text fields\n if 'characters' in data:\n novels_db[novel_id]['characters'] = data['characters']\n if 'knowledge' in data:\n novels_db[novel_id]['knowledge'] = data['knowledge']\n if 'style_prompt' in data:\n novels_db[novel_id]['style_prompt'] = data['style_prompt']\n # Add updates for tag arrays\n if 'character_tags' in data:\n novels_db[novel_id]['character_tags'] = data['character_tags']\n if 'glossary_tags' in data:\n novels_db[novel_id]['glossary_tags'] = data['glossary_tags']\n if 'style_tags' in data:\n novels_db[novel_id]['style_tags'] = data['style_tags']\n\n print(f\"Updated novel: {novel_id}\")\n save_novels() # 保存小说数据\n return jsonify({\"message\": \"Novel updated successfully\", \"id\": novel_id})\n\n@app.route('/api/novels/', methods=['DELETE'])\ndef delete_novel(novel_id):\n \"\"\"API endpoint to delete a specific novel.\"\"\"\n if novel_id in novels_db:\n deleted_title = novels_db[novel_id].get('title', 'Untitled')\n del novels_db[novel_id]\n print(f\"Deleted novel: {novel_id} - {deleted_title}\")\n save_novels() # 保存小说数据\n return jsonify({\"message\": f\"Novel '{deleted_title}' deleted successfully\"})\n else:\n print(f\"Attempted to delete non-existent novel: {novel_id}\")\n return jsonify({\"message\": \"Novel not found\"}), 404\n\n@app.route('/api/set-api-key', methods=['POST'])\ndef set_api_key():\n \"\"\"API endpoint to set and validate the OpenRouter API key.\"\"\"\n global OPENROUTER_API_KEY\n data = request.get_json()\n if not data or 'api_key' not in data:\n return jsonify({\"error\": \"请输入API密钥\"}), 400\n \n api_key = data['api_key'].strip()\n if not api_key:\n return jsonify({\"error\": \"API密钥不能为空\"}), 400\n \n # 验证API密钥格式\n if not api_key.startswith('sk-'):\n return jsonify({\"error\": \"无效的API密钥格式,OpenRouter API密钥应以'sk-'开头\"}), 400\n \n # 设置API密钥\n OPENROUTER_API_KEY = api_key\n \n # 保存API密钥到文件\n save_api_key()\n \n return jsonify({\"message\": \"API密钥已成功设置并保存\"})\n\n@app.route('/api/api-key-status', methods=['GET'])\ndef get_api_key_status():\n \"\"\"API endpoint to check if an API key is set.\"\"\"\n has_key = OPENROUTER_API_KEY is not None and len(str(OPENROUTER_API_KEY)) > 0\n \n key_preview = None\n if has_key and len(str(OPENROUTER_API_KEY)) >= 11: # 确保密钥长度足够生成预览\n key_preview = f\"{OPENROUTER_API_KEY[:7]}...{OPENROUTER_API_KEY[-4:]}\"\n \n return jsonify({\n \"has_key\": has_key,\n \"key_preview\": key_preview\n })\n\n@app.route('/api/generate', methods=['POST'])\ndef generate_text():\n \"\"\"API endpoint to generate text using OpenRouter or custom APIs.\"\"\"\n data = request.get_json()\n if not data or 'model' not in data or 'prompt' not in data:\n return jsonify({\"error\": \"Missing required fields: model and prompt\"}), 400\n\n model = data['model']\n prompt = data['prompt']\n temperature = data.get('temperature', 0.7) # Default temperature to 0.7\n\n # Check if this is a custom API model\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"Custom API configuration not found\"}), 404\n \n # Get the API configuration\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config['path'] or '' # Use empty string if path is not provided\n # --- NEW URL JOINING LOGIC ---\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n if clean_path:\n api_url = f\"{clean_base_url}/{clean_path}\"\n else:\n api_url = clean_base_url # Use base URL directly if path is empty\n # --- END NEW LOGIC ---\n secret = api_config['secret']\n model_name = api_config['modelName']\n \n # Prepare headers based on configuration\n headers = {\n \"Content-Type\": \"application/json\"\n }\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n # Prepare payload for custom API (assuming OpenAI-like format)\n custom_payload = {\n \"model\": model_name,\n \"messages\": [\n {\"role\": \"user\", \"content\": prompt}\n ],\n \"temperature\": float(temperature),\n \"stream\": True\n }\n \n # 调试用,打印请求信息\n print('请求URL:', api_url)\n print('请求Headers:', headers)\n print('请求Body:', json.dumps(custom_payload, ensure_ascii=False))\n \n try:\n # Create streaming response\n def event_stream():\n try:\n response = requests.post(\n api_url, \n headers=headers, \n json=custom_payload, \n stream=True\n )\n response.raise_for_status()\n \n # Stream the response\n for line in response.iter_lines():\n if line:\n decoded_line = line.decode('utf-8')\n if decoded_line.startswith('data: '):\n data_content = decoded_line[len('data: '):]\n if data_content.strip() == '[DONE]':\n print(\"Stream finished.\")\n break\n try:\n chunk = json.loads(data_content)\n # Extract token from response format\n if 'choices' in chunk and len(chunk['choices']) > 0:\n delta = chunk['choices'][0].get('delta', {})\n # 兼容 ModelScope reasoning_content\n reasoning = delta.get('reasoning_content')\n token = delta.get('content')\n if reasoning:\n yield f\"data: {json.dumps({'reasoning': reasoning})}\\n\\n\"\n if token:\n yield f\"data: {json.dumps({'token': token})}\\n\\n\"\n \n finish_reason = chunk['choices'][0].get('finish_reason')\n if finish_reason:\n print(f\"--> Stream finished with reason: {finish_reason}\")\n break\n except json.JSONDecodeError:\n print(f\"Error decoding stream JSON chunk: {data_content}\")\n elif decoded_line.strip():\n print(f\"Received non-data line: {decoded_line}\")\n except requests.exceptions.RequestException as e:\n error_message = f\"Error connecting to custom API: {e}\"\n print(error_message)\n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"Custom API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via custom API. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via custom API. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n \n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n except Exception as e:\n error_message = f\"An unexpected error occurred during custom API generation: {e}\"\n print(error_message)\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n finally:\n print(\"Closing stream generator.\")\n \n return Response(stream_with_context(event_stream()), mimetype='text/event-stream')\n except Exception as e:\n print(f\"Error setting up custom API request: {e}\")\n return jsonify({\"error\": f\"Failed to generate text via custom API: {str(e)}\"}), 500\n \n # If not a custom API, use OpenRouter (existing logic)\n if not OPENROUTER_API_KEY:\n return jsonify({\"error\": \"请先设置OpenRouter API密钥。您可以在页面顶部的设置区域中输入并保存API密钥。\"}), 401\n\n # --- OpenRouter API Request Logic ---\n openrouter_payload = {\n \"model\": model,\n \"messages\": [\n # OpenRouter uses a 'messages' array, typically with roles\n {\"role\": \"user\", \"content\": prompt}\n # Add system prompts or previous conversation history here if needed\n ],\n \"temperature\": float(temperature),\n \"stream\": True, # Enable streaming from OpenRouter\n # Add other OpenRouter parameters here if needed (e.g., max_tokens)\n }\n\n headers = {\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n # OpenRouter might suggest adding HTTP Referer or X-Title headers\n # \"HTTP-Referer\": $YOUR_SITE_URL, # Optional\n # \"X-Title\": $YOUR_SITE_NAME # Optional\n }\n\n try:\n # --- Streaming Response Handling ---\n def event_stream():\n try:\n # Use the OpenRouter chat completions endpoint with streaming enabled\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\", \n headers=headers, \n json=openrouter_payload, \n stream=True # Enable streaming in requests\n )\n response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)\n\n # Iterate over the response lines\n for line in response.iter_lines():\n if line:\n decoded_line = line.decode('utf-8')\n if decoded_line.startswith('data: '):\n data_content = decoded_line[len('data: '):]\n if data_content.strip() == '[DONE]': # Check for OpenRouter stream end signal\n print(\"Stream finished.\")\n break\n try:\n chunk = json.loads(data_content)\n # Extract token from OpenRouter's streaming format\n # Typically: chunk['choices'][0]['delta']['content']\n if 'choices' in chunk and len(chunk['choices']) > 0:\n delta = chunk['choices'][0].get('delta', {})\n # 兼容 ModelScope reasoning_content\n reasoning = delta.get('reasoning_content')\n token = delta.get('content')\n if reasoning:\n yield f\"data: {json.dumps({'reasoning': reasoning})}\\n\\n\"\n if token:\n yield f\"data: {json.dumps({'token': token})}\\n\\n\"\n \n finish_reason = chunk['choices'][0].get('finish_reason')\n if finish_reason:\n print(f\"--> Stream finished with reason: {finish_reason}\") # Enhanced logging\n # Optionally send a final signal\n # yield f\"data: {json.dumps({'event': 'finish', 'reason': finish_reason})}\\n\\n\"\n break # Stop sending after finish reason\n\n except json.JSONDecodeError:\n print(f\"Error decoding stream JSON chunk: {data_content}\")\n # yield f\"data: {json.dumps({'error': 'Invalid JSON chunk'})}\\n\\n\"\n # Continue to next line\n elif decoded_line.strip(): # Log other non-empty lines for debugging\n print(f\"Received non-data line: {decoded_line}\")\n\n except requests.exceptions.RequestException as e:\n # Handle connection errors, timeouts, etc.\n error_message = f\"Error connecting to OpenRouter: {e}\"\n print(error_message)\n # Try to get more specific error from response if available and it's a JSON response\n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"OpenRouter API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via OpenRouter. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via OpenRouter. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n \n # Send error back via SSE\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n except Exception as e:\n error_message = f\"An unexpected error occurred during OpenRouter generation: {e}\"\n print(error_message)\n yield f\"data: {json.dumps({'error': error_message})}\\n\\n\"\n finally:\n # Ensure a final signal is sent if needed, or just close\n print(\"Closing stream generator.\")\n # yield f\"data: [DONE]\\n\\n\" # Optionally send a custom end signal\n\n # Return the streaming response\n return Response(stream_with_context(event_stream()), mimetype='text/event-stream')\n\n except requests.exceptions.RequestException as e:\n # Handle connection errors, timeouts, etc.\n error_message = f\"Error connecting to OpenRouter: {e}\"\n print(error_message)\n # Try to get more specific error from response if available and it's a JSON response\n # --- This error handling is for the initial request, not the stream itself ---\n # --- Stream errors are handled within event_stream --- \n try:\n if e.response is not None:\n error_detail = e.response.json().get('error', {}).get('message', str(e))\n error_message = f\"OpenRouter API error (Status {e.response.status_code}): {error_detail}\"\n else:\n error_message = f\"Failed to generate text via OpenRouter. Request Error: {str(e)}\"\n except Exception:\n error_message = f\"Failed to generate text via OpenRouter. {str(e)}\"\n if e.response is not None:\n error_message += f\" Raw Response: {e.response.text[:200]}...\"\n\n return jsonify({\"error\": error_message}), 500\n except json.JSONDecodeError:\n print(f\"Error decoding JSON response from OpenRouter generation: {response.text}\")\n return jsonify({\"error\": \"Invalid response format from OpenRouter generation.\"}), 500\n except Exception as e:\n print(f\"An unexpected error occurred during OpenRouter generation: {e}\")\n return jsonify({\"error\": f\"An unexpected error occurred: {str(e)}\"}), 500\n\n# --- Export Endpoints (Server-Side) ---\n\ndef generate_export_filename(title, extension):\n \"\"\"Generates a sanitized filename with title and timestamp.\"\"\"\n # Sanitize title (allow alphanumeric and CJK)\n sanitized_title = ''.join(c for c in title if c.isalnum() or '\\u4e00' <= c <= '\\u9fff')\n sanitized_title = sanitized_title or \"Untitled\" # Fallback if title is empty after sanitizing\n timestamp = datetime.datetime.now().strftime(\"%Y-%m-%dT%H-%M-%S\")\n return f\"{sanitized_title}_{timestamp}.{extension}\"\n\n@app.route('/api/novels//export/', methods=['POST'])\ndef export_novel_serverside(novel_id, file_format):\n \"\"\"API endpoint to export a novel to a file on the server.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n\n novel_data = novels_db[novel_id]\n title = novel_data.get('title', 'Untitled')\n content = novel_data.get('content', '')\n characters = novel_data.get('characters', '')\n knowledge = novel_data.get('knowledge', '')\n style_prompt = novel_data.get('style_prompt', '')\n character_tags = novel_data.get('character_tags', [])\n glossary_tags = novel_data.get('glossary_tags', [])\n style_tags = novel_data.get('style_tags', [])\n\n try:\n if file_format == 'txt':\n filename = generate_export_filename(title, 'txt')\n filepath = os.path.join(EXPORT_DIR, filename)\n txt_content = f\"标题: {title}\\n\\n\"\n if characters: txt_content += f\"角色设定:\\n{characters}\\n\\n\"\n if style_prompt: txt_content += f\"风格提示:\\n{style_prompt}\\n\\n\"\n if knowledge: txt_content += f\"关联知识:\\n{knowledge}\\n\\n\"\n txt_content += f\"正文内容:\\n{content}\"\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(txt_content)\n\n elif file_format == 'json':\n filename = generate_export_filename(title, 'json')\n filepath = os.path.join(EXPORT_DIR, filename)\n export_data = {\n 'title': title,\n 'content': content,\n 'characters': characters,\n 'knowledge': knowledge,\n 'style_prompt': style_prompt,\n 'character_tags': character_tags,\n 'glossary_tags': glossary_tags,\n 'style_tags': style_tags\n }\n with open(filepath, 'w', encoding='utf-8') as f:\n json.dump(export_data, f, ensure_ascii=False, indent=4)\n\n elif file_format == 'docx':\n filename = generate_export_filename(title, 'docx')\n filepath = os.path.join(EXPORT_DIR, filename)\n\n document = Document()\n document.add_heading(title, level=0) # Title\n document.add_paragraph()\n\n if characters:\n document.add_heading('角色设定', level=2)\n for line in characters.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n if style_prompt:\n document.add_heading('风格提示', level=2)\n for line in style_prompt.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n if knowledge:\n document.add_heading('关联知识', level=2)\n for line in knowledge.split('\\n'):\n document.add_paragraph(line)\n document.add_paragraph()\n\n document.add_heading('正文内容', level=1)\n for line in content.split('\\n'): # Add content line by line\n document.add_paragraph(line)\n\n document.save(filepath)\n\n else:\n return jsonify({\"error\": \"Invalid file format requested\"}), 400\n\n print(f\"Exported novel '{title}' ({novel_id}) to {filepath}\")\n # Return path relative to server script for confirmation\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\"message\": f\"文件已成功导出至服务器: {relative_path}\", \"filepath\": relative_path})\n\n except Exception as e:\n print(f\"Error exporting novel {novel_id} to {file_format}: {e}\")\n return jsonify({\"error\": f\"导出文件时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/novels//export-notes', methods=['POST'])\ndef export_novel_notes(novel_id):\n \"\"\"API endpoint to export novel notes to a file in the exports folder.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data:\n logging.error(f\"[笔记导出] 未提供content,novel_id={novel_id}\")\n return jsonify({\"error\": \"No content provided\"}), 400\n\n try:\n # Create exports directory if it doesn't exist\n os.makedirs(EXPORT_DIR, exist_ok=True)\n\n # Generate filename with timestamp\n from datetime import datetime\n timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')\n filename = f'novel_notes_{novel_id}_{timestamp}.txt'\n filepath = os.path.join(EXPORT_DIR, filename)\n\n # Write content to file\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(data['content'])\n\n logging.info(f\"[笔记导出] 成功: {filepath}\")\n return jsonify({\n \"message\": f\"笔记已成功导出到: {filename}\",\n \"filepath\": filepath\n })\n\n except Exception as e:\n logging.error(f\"[笔记导出] Error exporting notes for novel {novel_id}: {str(e)}\")\n return jsonify({\"error\": f\"导出笔记失败: {str(e)}\"}), 500\n\n# --- Import Endpoint ---\n\n@app.route('/api/novels/import', methods=['POST'])\ndef import_novel():\n \"\"\"API endpoint to import a novel from uploaded file content.\"\"\"\n data = request.get_json()\n if not data or 'filename' not in data or 'content' not in data:\n return jsonify({\"error\": \"Missing filename or content\"}), 400\n\n filename = data['filename']\n file_content = data['content']\n file_ext = filename.split('.')[-1].lower()\n\n novel_data = {\n \"title\": \"Untitled Import\",\n \"content\": \"\",\n \"characters\": \"\",\n \"knowledge\": \"\",\n \"style_prompt\": \"\",\n \"character_tags\": [],\n \"glossary_tags\": [],\n \"style_tags\": []\n }\n\n try:\n if file_ext == 'json':\n # For JSON files, check if the content is already a JSON string or needs parsing\n try:\n # Try parsing the JSON content\n imported_data = json.loads(strip_code_block(file_content))\n novel_data['title'] = imported_data.get('title', f\"Imported {filename}\")\n novel_data['content'] = imported_data.get('content', '')\n novel_data['characters'] = imported_data.get('characters', '')\n novel_data['knowledge'] = imported_data.get('knowledge', '')\n novel_data['style_prompt'] = imported_data.get('style_prompt', '')\n # Import tag arrays if present\n novel_data['character_tags'] = imported_data.get('character_tags', [])\n novel_data['glossary_tags'] = imported_data.get('glossary_tags', [])\n novel_data['style_tags'] = imported_data.get('style_tags', [])\n except json.JSONDecodeError as json_error:\n # More detailed error message for JSON parsing issues\n detail = str(json_error)\n print(f\"JSON parse error: {detail}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无效的JSON格式: {detail}\"}), 400\n\n elif file_ext == 'txt':\n lines = file_content.splitlines()\n current_section = None\n content_buffer = []\n characters_buffer = []\n knowledge_buffer = []\n style_buffer = []\n\n for line in lines:\n line_strip = line.strip()\n if line_strip.startswith(\"标题:\"):\n novel_data['title'] = line_strip.split(\":\", 1)[1].strip() or f\"Imported {filename}\"\n current_section = None\n elif line_strip == \"角色设定:\":\n current_section = \"characters\"\n elif line_strip == \"风格提示:\":\n current_section = \"style_prompt\"\n elif line_strip == \"关联知识:\":\n current_section = \"knowledge\"\n elif line_strip == \"正文内容:\":\n current_section = \"content\"\n elif current_section == \"characters\":\n characters_buffer.append(line)\n elif current_section == \"style_prompt\":\n style_buffer.append(line)\n elif current_section == \"knowledge\":\n knowledge_buffer.append(line)\n elif current_section == \"content\":\n content_buffer.append(line)\n elif current_section is None and novel_data['title'] == \"Untitled Import\": # Try to find title if not explicitly tagged\n if line_strip: # Use first non-empty line as title if no header found yet\n novel_data['title'] = line_strip\n current_section = \"maybe_content\" # Assume following lines might be content\n\n elif current_section == \"maybe_content\": # If title was inferred, assume rest is content until a proper header\n content_buffer.append(line)\n\n\n novel_data['content'] = \"\\n\".join(content_buffer).strip()\n novel_data['characters'] = \"\\n\".join(characters_buffer).strip()\n novel_data['knowledge'] = \"\\n\".join(knowledge_buffer).strip()\n novel_data['style_prompt'] = \"\\n\".join(style_buffer).strip()\n\n # Basic fallback if no headers found at all\n if not novel_data['content'] and not novel_data['characters'] and not novel_data['knowledge'] and not novel_data['style_prompt']:\n if novel_data['title'] == \"Untitled Import\":\n novel_data['title'] = f\"Imported {filename}\"\n novel_data['content'] = file_content # Put everything in content\n \n elif file_ext == 'docx':\n # For Word documents, we need to convert base64 content to binary\n import base64\n \n try:\n # Convert base64 content to binary\n binary_content = base64.b64decode(file_content)\n \n # Read the Word document\n doc = Document(io.BytesIO(binary_content))\n \n paragraphs = [p.text for p in doc.paragraphs]\n \n # Try to identify sections based on headings\n current_section = \"maybe_content\"\n content_buffer = []\n characters_buffer = []\n knowledge_buffer = []\n style_buffer = []\n \n # First check if the document has a title (first paragraph or heading)\n if paragraphs and paragraphs[0].strip():\n novel_data[\"title\"] = paragraphs[0].strip()\n # Skip the title in further processing\n paragraphs = paragraphs[1:]\n else:\n novel_data[\"title\"] = f\"Imported {filename}\"\n \n for p in paragraphs:\n p_strip = p.strip()\n \n # Check for section headers\n if p_strip.lower() in [\"角色设定\", \"角色\"]:\n current_section = \"characters\"\n continue\n elif p_strip.lower() in [\"风格提示\", \"风格\"]:\n current_section = \"style_prompt\"\n continue\n elif p_strip.lower() in [\"关联知识\", \"知识\"]:\n current_section = \"knowledge\"\n continue\n elif p_strip.lower() in [\"正文内容\", \"正文\", \"内容\"]:\n current_section = \"content\"\n continue\n \n # Add paragraph to appropriate buffer\n if current_section == \"characters\":\n characters_buffer.append(p)\n elif current_section == \"style_prompt\":\n style_buffer.append(p)\n elif current_section == \"knowledge\":\n knowledge_buffer.append(p)\n elif current_section == \"content\" or current_section == \"maybe_content\":\n content_buffer.append(p)\n \n # Join the buffers and store in novel_data\n novel_data['content'] = \"\\n\".join(content_buffer).strip()\n novel_data['characters'] = \"\\n\".join(characters_buffer).strip()\n novel_data['knowledge'] = \"\\n\".join(knowledge_buffer).strip()\n novel_data['style_prompt'] = \"\\n\".join(style_buffer).strip()\n \n # If no structured content was found, put everything in content\n if not novel_data['content'] and not novel_data['characters'] and not novel_data['knowledge'] and not novel_data['style_prompt']:\n novel_data['content'] = \"\\n\".join(paragraphs).strip()\n except base64.binascii.Error as base64_error:\n print(f\"Base64 decode error: {str(base64_error)}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无法解码Word文档: {str(base64_error)}\"}), 400\n except Exception as docx_error:\n print(f\"DOCX processing error: {str(docx_error)}\")\n return jsonify({\"error\": f\"处理Word文档时出错: {str(docx_error)}\"}), 400\n\n else:\n return jsonify({\"error\": \"不支持的文件格式 (仅支持 .txt, .json, .docx)\"}), 400\n\n # 统一设置标题为\"用户导入\"\n novel_data['title'] = \"用户导入\"\n\n # Create new novel entry\n novel_id = str(uuid.uuid4())\n novels_db[novel_id] = novel_data\n print(f\"Imported novel: {novel_id} - {novel_data['title']}\")\n save_novels() # 保存小说数据\n\n # Return the details of the imported novel\n return jsonify({\n \"message\": \"Novel imported successfully\",\n \"id\": novel_id,\n \"title\": novel_data[\"title\"],\n \"content\": novel_data[\"content\"],\n \"characters\": novel_data[\"characters\"],\n \"knowledge\": novel_data[\"knowledge\"],\n \"style_prompt\": novel_data[\"style_prompt\"]\n }), 201\n\n except json.JSONDecodeError as json_error:\n error_message = f\"无效的JSON格式: {str(json_error)}\"\n print(f\"JSON parsing error: {error_message}\")\n return jsonify({\"error\": error_message}), 400\n except Exception as e:\n error_message = f\"导入小说时发生错误: {str(e)}\"\n print(f\"Error importing novel from {filename}: {e}\")\n return jsonify({\"error\": error_message}), 500\n\n# --- Character Library Endpoints ---\n\n@app.route('/api/characters', methods=['GET'])\ndef get_characters():\n \"\"\"API endpoint to list all characters in the library.\"\"\"\n # Return a list of characters with id and name\n character_list = [{\"id\": char_id, \"name\": data.get(\"name\", \"未命名\"), \"description\": data.get(\"description\", \"\")[:50] + \"...\" if len(data.get(\"description\", \"\")) > 50 else data.get(\"description\", \"\")} \n for char_id, data in characters_db.items()]\n return jsonify(character_list)\n\n@app.route('/api/characters', methods=['POST'])\ndef create_character():\n \"\"\"API endpoint to create a new character.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data:\n return jsonify({\"error\": \"角色名称是必填项\"}), 400\n\n character_id = str(uuid.uuid4()) # Generate a unique ID\n new_character = {\n \"name\": data['name'],\n \"description\": data.get('description', ''), # Character description\n \"details\": data.get('details', {}) # Additional details (could be skills, backstory, etc.)\n }\n characters_db[character_id] = new_character\n print(f\"Created character: {character_id} - {data['name']}\")\n save_characters() # 保存角色数据\n \n return jsonify({\n \"id\": character_id, \n \"name\": new_character[\"name\"],\n \"description\": new_character[\"description\"],\n \"details\": new_character[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/characters/', methods=['GET'])\ndef get_character(character_id):\n \"\"\"API endpoint to get details of a specific character.\"\"\"\n character = characters_db.get(character_id)\n if character:\n return jsonify({\n \"id\": character_id,\n \"name\": character.get(\"name\", \"未命名\"),\n \"description\": character.get(\"description\", \"\"),\n \"details\": character.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"角色不存在\"}), 404\n\n@app.route('/api/characters/', methods=['PUT', 'PATCH'])\ndef update_character(character_id):\n \"\"\"API endpoint to update a character's information.\"\"\"\n if character_id not in characters_db:\n return jsonify({\"error\": \"角色不存在\"}), 404\n \n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n \n # Update fields provided in the request\n if 'name' in data:\n characters_db[character_id]['name'] = data['name']\n if 'description' in data:\n characters_db[character_id]['description'] = data['description']\n if 'details' in data:\n characters_db[character_id]['details'] = data['details']\n \n save_characters() # 保存角色数据\n return jsonify({\n \"message\": \"角色已更新\", \n \"id\": character_id\n })\n\n@app.route('/api/characters/', methods=['DELETE'])\ndef delete_character(character_id):\n \"\"\"API endpoint to delete a specific character.\"\"\"\n if character_id in characters_db:\n deleted_name = characters_db[character_id].get('name', '未命名')\n del characters_db[character_id]\n print(f\"Deleted character: {character_id} - {deleted_name}\")\n save_characters() # 保存角色数据\n return jsonify({\"message\": f\"角色 '{deleted_name}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"角色不存在\"}), 404\n\n# --- Glossary Endpoints ---\n\n@app.route('/api/glossary', methods=['GET'])\ndef get_glossary_entries():\n \"\"\"API endpoint to list all glossary entries.\"\"\"\n # Return a list of glossary entries with id, term and brief description\n glossary_list = [{\"id\": entry_id, \n \"term\": data.get(\"term\", \"未命名词条\"), \n \"category\": data.get(\"category\", \"\"),\n \"description\": data.get(\"description\", \"\")[:50] + \"...\" if len(data.get(\"description\", \"\")) > 50 else data.get(\"description\", \"\")} \n for entry_id, data in glossary_db.items()]\n return jsonify(glossary_list)\n\n@app.route('/api/glossary', methods=['POST'])\ndef create_glossary_entry():\n \"\"\"API endpoint to create a new glossary entry.\"\"\"\n data = request.get_json()\n if not data or 'term' not in data:\n return jsonify({\"error\": \"词条名称是必填项\"}), 400\n\n entry_id = str(uuid.uuid4()) # Generate a unique ID\n new_entry = {\n \"term\": data['term'],\n \"category\": data.get('category', ''), # Category for organization\n \"description\": data.get('description', ''), # Description of the term\n \"details\": data.get('details', {}) # Additional details if needed\n }\n glossary_db[entry_id] = new_entry\n print(f\"Created glossary entry: {entry_id} - {data['term']}\")\n save_glossary() # 保存词条数据\n \n return jsonify({\n \"id\": entry_id, \n \"term\": new_entry[\"term\"],\n \"category\": new_entry[\"category\"],\n \"description\": new_entry[\"description\"],\n \"details\": new_entry[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/glossary/', methods=['GET'])\ndef get_glossary_entry(entry_id):\n \"\"\"API endpoint to get details of a specific glossary entry.\"\"\"\n entry = glossary_db.get(entry_id)\n if entry:\n return jsonify({\n \"id\": entry_id,\n \"term\": entry.get(\"term\", \"未命名词条\"),\n \"category\": entry.get(\"category\", \"\"),\n \"description\": entry.get(\"description\", \"\"),\n \"details\": entry.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n@app.route('/api/glossary/', methods=['PUT', 'PATCH'])\ndef update_glossary_entry(entry_id):\n \"\"\"API endpoint to update a glossary entry.\"\"\"\n if entry_id not in glossary_db:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n\n # Update fields provided in the request\n if 'term' in data:\n glossary_db[entry_id]['term'] = data['term']\n if 'category' in data:\n glossary_db[entry_id]['category'] = data['category']\n if 'description' in data:\n glossary_db[entry_id]['description'] = data['description']\n if 'details' in data:\n glossary_db[entry_id]['details'] = data['details']\n\n save_glossary() # 保存词条数据\n return jsonify({\n \"message\": \"词条已更新\", \n \"id\": entry_id\n })\n\n@app.route('/api/glossary/', methods=['DELETE'])\ndef delete_glossary_entry(entry_id):\n \"\"\"API endpoint to delete a specific glossary entry.\"\"\"\n if entry_id in glossary_db:\n deleted_term = glossary_db[entry_id].get('term', '未命名词条')\n del glossary_db[entry_id]\n print(f\"Deleted glossary entry: {entry_id} - {deleted_term}\")\n save_glossary() # 保存词条数据\n return jsonify({\"message\": f\"词条 '{deleted_term}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"词条不存在\"}), 404\n\n@app.route('/api/novels//glossary', methods=['GET'])\ndef get_novel_glossary(novel_id):\n \"\"\"API endpoint to get glossary entries associated with a specific novel.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"小说不存在\"}), 404\n \n # In a more advanced implementation, we might have a relationship table\n # For now, we'll return all glossary entries (could be filtered by novel later)\n return get_glossary_entries()\n\n# --- Style Library Endpoints ---\n\n@app.route('/api/styles', methods=['GET'])\ndef get_styles():\n \"\"\"API endpoint to list all style entries.\"\"\"\n # Return a list of style entries with id, name and brief description\n style_list = [{\"id\": style_id, \n \"name\": data.get(\"name\", \"未命名风格\"), \n \"category\": data.get(\"category\", \"\"),\n \"content\": data.get(\"content\", \"\")[:50] + \"...\" if len(data.get(\"content\", \"\")) > 50 else data.get(\"content\", \"\")} \n for style_id, data in styles_db.items()]\n return jsonify(style_list)\n\n@app.route('/api/styles', methods=['POST'])\ndef create_style():\n \"\"\"API endpoint to create a new style entry.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data:\n return jsonify({\"error\": \"风格名称是必填项\"}), 400\n\n style_id = str(uuid.uuid4()) # Generate a unique ID\n new_style = {\n \"name\": data['name'],\n \"category\": data.get('category', ''), # Category for organization\n \"content\": data.get('content', ''), # The actual style prompt content\n \"details\": data.get('details', {}) # Additional details if needed\n }\n styles_db[style_id] = new_style\n print(f\"Created style entry: {style_id} - {data['name']}\")\n save_styles() # 保存风格数据\n \n return jsonify({\n \"id\": style_id, \n \"name\": new_style[\"name\"],\n \"category\": new_style[\"category\"],\n \"content\": new_style[\"content\"],\n \"details\": new_style[\"details\"]\n }), 201 # 201 Created status\n\n@app.route('/api/styles/import-md', methods=['POST'])\ndef import_md_as_style():\n \"\"\"API endpoint to import a markdown file from the prompt folder as a style entry.\"\"\"\n data = request.get_json()\n if not data or 'filename' not in data:\n return jsonify({\"error\": \"文件名是必填项\"}), 400\n\n try:\n # Determine the prompt folder path\n if getattr(sys, 'frozen', False):\n prompt_folder = os.path.join(os.path.dirname(sys.executable), 'prompt')\n else:\n prompt_folder = os.path.join(os.path.dirname(__file__), 'prompt')\n \n # Ensure the filename has the .md extension\n filename = data['filename']\n if not filename.lower().endswith('.md'):\n filename += '.md'\n \n file_path = os.path.join(prompt_folder, filename)\n \n if not os.path.exists(file_path):\n return jsonify({\"error\": f\"找不到文件: {filename}\"}), 404\n \n # Read the markdown file content\n with open(file_path, 'r', encoding='utf-8') as f:\n content = f.read()\n \n # Extract name from filename (remove extension)\n name = os.path.splitext(filename)[0]\n \n # Create a new style entry\n style_id = str(uuid.uuid4())\n new_style = {\n \"name\": name,\n \"category\": data.get('category', '导入提示词'), # Default category or user-provided\n \"content\": content,\n \"details\": {\n \"imported\": True,\n \"source\": file_path,\n \"import_date\": datetime.datetime.now().isoformat()\n }\n }\n \n styles_db[style_id] = new_style\n print(f\"Imported style from MD file: {style_id} - {name}\")\n save_styles() # 保存风格数据\n \n return jsonify({\n \"id\": style_id, \n \"name\": new_style[\"name\"],\n \"category\": new_style[\"category\"],\n \"content\": new_style[\"content\"],\n \"details\": new_style[\"details\"]\n }), 201\n except Exception as e:\n return jsonify({\"error\": f\"导入提示词文件时出错: {str(e)}\"}), 500\n\n@app.route('/api/styles/list-md-files', methods=['GET'])\ndef list_md_files():\n \"\"\"API endpoint to list all markdown files in the prompt folder.\"\"\"\n try:\n # Determine the prompt folder path\n if getattr(sys, 'frozen', False):\n prompt_folder = os.path.join(os.path.dirname(sys.executable), 'prompt')\n else:\n prompt_folder = os.path.join(os.path.dirname(__file__), 'prompt')\n \n # Get all .md files in the prompt folder\n md_files = [f for f in os.listdir(prompt_folder) if f.lower().endswith('.md')]\n \n return jsonify(md_files)\n except Exception as e:\n return jsonify({\"error\": f\"列出提示词文件时出错: {str(e)}\"}), 500\n\n@app.route('/api/styles/', methods=['GET'])\ndef get_style(style_id):\n \"\"\"API endpoint to get details of a specific style entry.\"\"\"\n style = styles_db.get(style_id)\n if style:\n return jsonify({\n \"id\": style_id,\n \"name\": style.get(\"name\", \"未命名风格\"),\n \"category\": style.get(\"category\", \"\"),\n \"content\": style.get(\"content\", \"\"),\n \"details\": style.get(\"details\", {})\n })\n else:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n@app.route('/api/styles/', methods=['PUT', 'PATCH'])\ndef update_style(style_id):\n \"\"\"API endpoint to update a style entry.\"\"\"\n if style_id not in styles_db:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"未提供更新数据\"}), 400\n\n # Update fields provided in the request\n if 'name' in data:\n styles_db[style_id]['name'] = data['name']\n if 'category' in data:\n styles_db[style_id]['category'] = data['category']\n if 'content' in data:\n styles_db[style_id]['content'] = data['content']\n if 'details' in data:\n styles_db[style_id]['details'] = data['details']\n\n save_styles() # 保存风格数据\n return jsonify({\n \"message\": \"风格已更新\", \n \"id\": style_id\n })\n\n@app.route('/api/styles/', methods=['DELETE'])\ndef delete_style(style_id):\n \"\"\"API endpoint to delete a specific style entry.\"\"\"\n if style_id in styles_db:\n deleted_name = styles_db[style_id].get('name', '未命名风格')\n del styles_db[style_id]\n print(f\"Deleted style entry: {style_id} - {deleted_name}\")\n save_styles() # 保存风格数据\n return jsonify({\"message\": f\"风格 '{deleted_name}' 已成功删除\"})\n else:\n return jsonify({\"error\": \"风格不存在\"}), 404\n\n@app.route('/api/novels//styles', methods=['GET'])\ndef get_novel_styles(novel_id):\n \"\"\"API endpoint to get style entries associated with a specific novel.\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"小说不存在\"}), 404\n \n # In a more advanced implementation, we might have a relationship table\n # For now, we'll return all style entries (could be filtered by novel later)\n return get_styles()\n\n@app.route('/api/games', methods=['GET'])\ndef get_games():\n \"\"\"API endpoint to list all games.\"\"\"\n game_list = [{\"id\": game_id, \"title\": data.get(\"title\", \"Untitled Game\")} for game_id, data in games_db.items()]\n return jsonify(game_list)\n\n@app.route('/api/games', methods=['POST'])\ndef create_game():\n \"\"\"API endpoint to create a new game.\"\"\"\n data = request.get_json()\n \n game_id = str(uuid.uuid4()) # Generate a unique ID\n new_game = {\n \"title\": data.get('title', 'New Game'),\n \"character\": data.get('character', ''),\n \"world\": data.get('world', ''),\n \"style\": data.get('style', ''),\n \"chat_history\": data.get('chat_history', []),\n \"created_at\": datetime.datetime.now().isoformat()\n }\n games_db[game_id] = new_game\n print(f\"Created game: {game_id} - {new_game['title']}\")\n save_games() # 保存游戏数据\n return jsonify({\"id\": game_id, \"title\": new_game[\"title\"]}), 201 # 201 Created status\n\n@app.route('/api/games/', methods=['GET'])\ndef get_game(game_id):\n \"\"\"API endpoint to get details of a specific game.\"\"\"\n game = games_db.get(game_id)\n if game:\n return jsonify({\n \"id\": game_id,\n \"title\": game.get(\"title\", \"Untitled\"),\n \"character\": game.get(\"character\", \"\"),\n \"world\": game.get(\"world\", \"\"),\n \"style\": game.get(\"style\", \"\"),\n \"chat_history\": game.get(\"chat_history\", []),\n \"created_at\": game.get(\"created_at\", \"\")\n })\n else:\n return jsonify({\"message\": \"Game not found\"}), 404\n\n@app.route('/api/games/', methods=['PUT', 'PATCH'])\ndef update_game(game_id):\n \"\"\"API endpoint to update a game's settings and history.\"\"\"\n if game_id not in games_db:\n return jsonify({\"message\": \"Game not found\"}), 404\n\n data = request.get_json()\n if not data:\n return jsonify({\"message\": \"No update data provided\"}), 400\n\n # Update fields provided in the request\n if 'title' in data:\n games_db[game_id]['title'] = data['title']\n if 'character' in data:\n games_db[game_id]['character'] = data['character']\n if 'world' in data:\n games_db[game_id]['world'] = data['world']\n if 'style' in data:\n games_db[game_id]['style'] = data['style']\n if 'chat_history' in data:\n games_db[game_id]['chat_history'] = data['chat_history']\n \n save_games() # 保存游戏数据\n return jsonify({\"message\": \"Game updated successfully\"})\n\n@app.route('/api/games/', methods=['DELETE'])\ndef delete_game(game_id):\n \"\"\"API endpoint to delete a game.\"\"\"\n if game_id not in games_db:\n return jsonify({\"message\": \"Game not found\"}), 404\n \n deleted_title = games_db[game_id].get('title', 'Unknown')\n del games_db[game_id]\n save_games() # 保存游戏数据\n \n return jsonify({\"message\": f\"Game '{deleted_title}' deleted successfully\"})\n\n# --- Export Endpoints (Chat History) ---\n\n@app.route('/api/chat/export', methods=['POST'])\ndef export_chat_history():\n \"\"\"API endpoint to export chat history to a JSON file.\"\"\"\n data = request.get_json()\n if not data or 'chat_history' not in data or 'settings' not in data:\n return jsonify({\"error\": \"Missing required chat data\"}), 400\n\n chat_history = data['chat_history']\n settings = data['settings']\n \n try:\n # Generate a filename with timestamp\n timestamp = datetime.datetime.now().strftime(\"%Y-%m-%dT%H-%M-%S\")\n filename = f\"Chat_Export_{timestamp}.json\"\n filepath = os.path.join(EXPORT_DIR, filename)\n \n # Prepare export data\n export_data = {\n 'timestamp': timestamp,\n 'settings': settings,\n 'chat_history': chat_history\n }\n \n # Write to file\n with open(filepath, 'w', encoding='utf-8') as f:\n json.dump(export_data, f, ensure_ascii=False, indent=4)\n \n print(f\"Exported chat history to {filepath}\")\n # Return path relative to server script for confirmation\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\n \"message\": f\"聊天记录已成功导出至服务器: {relative_path}\", \n \"filepath\": relative_path\n })\n \n except Exception as e:\n print(f\"Error exporting chat history: {e}\")\n return jsonify({\"error\": f\"导出聊天记录时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/chat/import', methods=['POST'])\ndef import_chat_history():\n \"\"\"API endpoint to import chat history from a JSON file content.\"\"\"\n data = request.get_json()\n if not data or 'file_content' not in data:\n return jsonify({\"error\": \"Missing file content\"}), 400\n\n file_content = data['file_content']\n \n try:\n # Parse the JSON content\n imported_data = json.loads(strip_code_block(file_content))\n \n # Validate the imported data\n if 'chat_history' not in imported_data or 'settings' not in imported_data:\n return jsonify({\"error\": \"无效的聊天记录文件格式\"}), 400\n \n return jsonify({\n \"message\": \"聊天记录已成功导入\",\n \"chat_data\": imported_data\n })\n \n except json.JSONDecodeError as json_error:\n # More detailed error message for JSON parsing issues\n detail = str(json_error)\n print(f\"JSON parse error: {detail}. Content preview: {file_content[:100]}...\")\n return jsonify({\"error\": f\"无效的JSON格式: {detail}\"}), 400\n except Exception as e:\n print(f\"Error importing chat history: {e}\")\n return jsonify({\"error\": f\"导入聊天记录时发生错误: {str(e)}\"}), 500\n\n@app.route('/api/custom-apis', methods=['GET'])\ndef get_custom_apis():\n \"\"\"API endpoint to get the list of saved custom APIs.\"\"\"\n try:\n return jsonify(list(CUSTOM_API_CONFIGS.values()))\n except Exception as e:\n print(f\"Error fetching custom APIs: {e}\")\n return jsonify({\"error\": \"Could not retrieve custom API list.\"}), 500\n\n@app.route('/api/custom-apis', methods=['POST'])\ndef add_custom_api():\n \"\"\"API endpoint to add a new custom API configuration.\"\"\"\n data = request.get_json()\n if not data or 'name' not in data or 'baseUrl' not in data or 'modelName' not in data:\n return jsonify({\"error\": \"Missing required fields\"}), 400\n \n api_id = str(uuid.uuid4())\n new_api = {\n \"id\": api_id,\n \"name\": data['name'],\n \"baseUrl\": data['baseUrl'],\n \"path\": data.get('path', ''),\n \"secret\": data.get('secret', ''),\n \"modelName\": data['modelName']\n }\n \n CUSTOM_API_CONFIGS[api_id] = new_api\n print(f\"Added custom API: {api_id} - {data['name']} - {data['baseUrl']}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify(new_api), 201\n\n@app.route('/api/custom-apis/', methods=['PUT'])\ndef update_custom_api(api_id):\n \"\"\"API endpoint to update an existing custom API configuration.\"\"\"\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"API configuration not found\"}), 404\n \n data = request.get_json()\n if not data:\n return jsonify({\"error\": \"No update data provided\"}), 400\n \n # Update fields\n CUSTOM_API_CONFIGS[api_id].update({\n \"name\": data.get('name', CUSTOM_API_CONFIGS[api_id]['name']),\n \"baseUrl\": data.get('baseUrl', CUSTOM_API_CONFIGS[api_id]['baseUrl']),\n \"path\": data.get('path', CUSTOM_API_CONFIGS[api_id]['path']),\n \"secret\": data.get('secret', CUSTOM_API_CONFIGS[api_id]['secret']),\n \"modelName\": data.get('modelName', CUSTOM_API_CONFIGS[api_id]['modelName'])\n })\n \n print(f\"Updated custom API: {api_id}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify(CUSTOM_API_CONFIGS[api_id])\n\n@app.route('/api/custom-apis/', methods=['DELETE'])\ndef delete_custom_api(api_id):\n \"\"\"API endpoint to delete a custom API configuration.\"\"\"\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({\"error\": \"API configuration not found\"}), 404\n \n deleted_name = CUSTOM_API_CONFIGS[api_id]['name']\n del CUSTOM_API_CONFIGS[api_id]\n print(f\"Deleted custom API: {api_id} - {deleted_name}\")\n \n # 保存到文件\n save_api_configs()\n \n return jsonify({\"message\": f\"Custom API '{deleted_name}' deleted successfully\"})\n\n@app.route('/api/novel-types', methods=['GET'])\ndef get_novel_types():\n \"\"\"API endpoint to get novel types and subtypes from the 小说类型.txt file.\"\"\"\n try:\n novel_types = {}\n \n # Read the 小说类型.txt file\n novel_types_file = os.path.join(DATA_DIR, '小说类型.txt')\n if not os.path.exists(novel_types_file):\n logging.error(f\"小说类型文件不存在: {novel_types_file}\")\n return jsonify({\"error\": f\"小说类型文件不存在: {novel_types_file}\"}), 500\n with open(novel_types_file, 'r', encoding='utf-8') as file:\n lines = file.readlines()\n \n # Parse each line\n for line in lines:\n line = line.strip()\n if not line:\n continue\n \n # Extract main type and subtypes\n if '类型选项及展开子选项:' in line:\n parts = line.split('类型选项及展开子选项:')\n main_type = parts[0].strip()\n \n # Extract subtypes\n subtypes_text = parts[1].strip()\n if subtypes_text.endswith('。'):\n subtypes_text = subtypes_text[:-1] # Remove trailing period\n \n # Split by commas or Chinese commas\n subtypes = []\n for subtype in re.split('、|,|,', subtypes_text):\n clean_subtype = subtype.strip()\n if clean_subtype and clean_subtype not in ('包括', '等'):\n subtypes.append(clean_subtype)\n \n novel_types[main_type] = subtypes\n \n return jsonify(novel_types)\n except Exception as e:\n print(f\"Error loading novel types: {str(e)}\")\n return jsonify({\"error\": f\"Failed to load novel types: {str(e)}\"}), 500\n\n@app.route('/api/save-text-file', methods=['POST'])\ndef save_text_file():\n \"\"\"API endpoint to save text file to the exports directory.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data or 'filename' not in data:\n return jsonify({\"error\": \"Missing required fields: content or filename\"}), 400\n \n content = data['content']\n filename = data['filename']\n \n # Sanitize filename (allow alphanumeric, CJK characters, and some symbols)\n sanitized_filename = ''.join(c for c in filename if c.isalnum() or '\\u4e00' <= c <= '\\u9fff' or c in '-_. ')\n sanitized_filename = sanitized_filename or \"unnamed_file.txt\" # Fallback if filename is empty after sanitizing\n \n try:\n # Ensure the exports directory exists\n if not os.path.exists(EXPORT_DIR):\n os.makedirs(EXPORT_DIR)\n \n # Create the full path\n filepath = os.path.join(EXPORT_DIR, sanitized_filename)\n \n # Write the content to the file\n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(content)\n \n # Return success response with relative path\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError:\n relative_path = filepath\n return jsonify({\n \"message\": \"文件已成功保存\",\n \"filepath\": relative_path\n })\n \n except Exception as e:\n error_message = f\"保存文件时出错: {str(e)}\"\n print(error_message)\n return jsonify({\"error\": error_message}), 500\n\n@app.route('/api/prompt-editor/export-template', methods=['POST'])\ndef export_prompt_template():\n \"\"\"API endpoint to save prompt editor content as a .md template file in the model directory.\"\"\"\n data = request.get_json()\n if not data or 'content' not in data or 'filename' not in data:\n return jsonify({\"error\": \"Missing required fields: content or filename\"}), 400\n\n content = data['content']\n filename = data['filename']\n\n # Sanitize filename (similar to other export functions)\n # Allow alphanumeric, CJK characters, and some symbols. Ensure .md extension.\n sanitized_filename = ''.join(c for c in filename if c.isalnum() or '\\\\u4e00' <= c <= '\\\\u9fff' or c in '-_. ')\n if not sanitized_filename: # Fallback if filename is empty after sanitizing\n sanitized_filename = \"prompt_template\"\n \n if not sanitized_filename.lower().endswith('.md'):\n sanitized_filename += '.md'\n \n try:\n # Ensure the model directory exists (it should be created on app start, but double-check)\n if not os.path.exists(MODEL_DIR):\n os.makedirs(MODEL_DIR)\n \n filepath = os.path.join(MODEL_DIR, sanitized_filename)\n \n with open(filepath, 'w', encoding='utf-8') as f:\n f.write(content)\n \n print(f\"Exported prompt template to {filepath}\")\n \n # Return path relative to server script for confirmation (optional, but consistent)\n try:\n relative_path = os.path.relpath(filepath, os.path.dirname(__file__))\n except ValueError: # Handles cases where paths are on different drives (e.g., during PyInstaller build in temp)\n relative_path = filepath \n \n return jsonify({\n \"message\": f\"模板已成功导出至服务器 'model' 文件夹: {sanitized_filename}\",\n \"filepath\": relative_path # Sending back the relative path for consistency\n })\n \n except Exception as e:\n error_message = f\"导出模板文件时出错: {str(e)}\"\n print(error_message)\n return jsonify({\"error\": error_message}), 500\n\n# novels_db 示例 (Add the chapters field to the novel structure)\nnovels_db = {\n \"novel_id_1\": {\n \"title\": \"我的第一部小说\",\n \"content\": \"...\", # 可能废弃或作为默认/未分章节内容\n \"characters\": \"...\",\n \"knowledge\": \"...\",\n \"style_prompt\": \"...\",\n \"character_tags\": [],\n \"glossary_tags\": [],\n \"style_tags\": [],\n \"chapters\": [] # Add empty chapters list to store chapter data\n }\n}\n\n# Chapter Management API Endpoints\n@app.route('/api/novels//chapters', methods=['GET'])\ndef get_novel_chapters(novel_id):\n \"\"\"Get all chapters for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # If no chapters field exists yet, initialize it\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n \n # Return chapters without content to reduce payload size\n chapters = []\n for chapter in novels_db[novel_id][\"chapters\"]:\n chapter_copy = {\n \"id\": chapter[\"id\"],\n \"title\": chapter[\"title\"],\n \"order\": chapter.get(\"order\", 0),\n \"created_at\": chapter.get(\"created_at\", \"\")\n }\n chapters.append(chapter_copy)\n \n return jsonify(chapters)\n\n@app.route('/api/novels//chapters', methods=['POST'])\ndef create_novel_chapter(novel_id):\n \"\"\"Create a new chapter for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Get request data\n data = request.json or {}\n title = data.get(\"title\", \"新章节\")\n \n # Initialize chapters list if it doesn't exist\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n \n # Create a new chapter with UUID\n now = datetime.datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n new_chapter = {\n \"id\": str(uuid.uuid4()),\n \"title\": title,\n \"content\": \"\",\n \"order\": len(novels_db[novel_id][\"chapters\"]), # Add at the end\n \"created_at\": now\n }\n \n # Add the new chapter to the novel's chapters\n novels_db[novel_id][\"chapters\"].append(new_chapter)\n \n # Save to disk\n save_novels()\n \n # Return the new chapter (without content)\n chapter_copy = {key: val for key, val in new_chapter.items() if key != \"content\"}\n return jsonify(chapter_copy), 201\n\n@app.route('/api/novels//chapters/', methods=['GET'])\ndef get_novel_chapter(novel_id, chapter_id):\n \"\"\"Get a specific chapter with its content\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Find the chapter by ID\n for chapter in novels_db[novel_id][\"chapters\"]:\n if chapter[\"id\"] == chapter_id:\n return jsonify(chapter)\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/', methods=['PUT'])\ndef update_novel_chapter(novel_id, chapter_id):\n \"\"\"Update a specific chapter\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Get request data\n data = request.json or {}\n \n # Find and update the chapter\n for i, chapter in enumerate(novels_db[novel_id][\"chapters\"]):\n if chapter[\"id\"] == chapter_id:\n # Update fields that were provided\n if \"title\" in data:\n novels_db[novel_id][\"chapters\"][i][\"title\"] = data[\"title\"]\n if \"content\" in data:\n novels_db[novel_id][\"chapters\"][i][\"content\"] = data[\"content\"]\n if \"order\" in data:\n novels_db[novel_id][\"chapters\"][i][\"order\"] = data[\"order\"]\n \n # Save changes\n save_novels()\n \n # Return the updated chapter\n return jsonify(novels_db[novel_id][\"chapters\"][i])\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/', methods=['DELETE'])\ndef delete_novel_chapter(novel_id, chapter_id):\n \"\"\"Delete a specific chapter\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n return jsonify({\"error\": \"Chapter not found\"}), 404\n \n # Find and remove the chapter\n for i, chapter in enumerate(novels_db[novel_id][\"chapters\"]):\n if chapter[\"id\"] == chapter_id:\n # Remove the chapter\n removed_chapter = novels_db[novel_id][\"chapters\"].pop(i)\n \n # Reorder remaining chapters if needed\n for j, ch in enumerate(novels_db[novel_id][\"chapters\"]):\n ch[\"order\"] = j\n \n # Save changes\n save_novels()\n \n return jsonify({\"message\": f\"Chapter '{removed_chapter['title']}' deleted successfully\"})\n \n return jsonify({\"error\": \"Chapter not found\"}), 404\n\n@app.route('/api/novels//chapters/reorder', methods=['PUT'])\ndef reorder_novel_chapters(novel_id):\n \"\"\"Reorder chapters for a specific novel\"\"\"\n if novel_id not in novels_db:\n return jsonify({\"error\": \"Novel not found\"}), 404\n \n # Check if chapters field exists\n if \"chapters\" not in novels_db[novel_id]:\n novels_db[novel_id][\"chapters\"] = []\n return jsonify({\"message\": \"No chapters to reorder\"}), 200\n \n # Get request data - expecting an array of chapter IDs in the new order\n data = request.json or []\n \n if not isinstance(data, list):\n return jsonify({\"error\": \"Invalid data format. Expected array of chapter IDs with order\"}), 400\n \n # Create a map of chapter IDs to their current objects\n chapter_map = {chapter[\"id\"]: chapter for chapter in novels_db[novel_id][\"chapters\"]}\n \n # Create a new ordered list of chapters\n new_chapters = []\n \n # Process each item in the request\n for i, item in enumerate(data):\n if isinstance(item, dict) and \"id\" in item:\n chapter_id = item[\"id\"]\n if chapter_id in chapter_map:\n # Get the original chapter and update its order\n chapter = chapter_map[chapter_id].copy()\n chapter[\"order\"] = i\n new_chapters.append(chapter)\n \n # If any chapters were missed in the request, append them at the end\n missed_chapters = [chapter for chapter_id, chapter in chapter_map.items() \n if chapter_id not in [item[\"id\"] for item in data if isinstance(item, dict) and \"id\" in item]]\n \n for i, chapter in enumerate(missed_chapters):\n chapter[\"order\"] = len(new_chapters) + i\n new_chapters.append(chapter)\n \n # Replace the chapters list\n novels_db[novel_id][\"chapters\"] = new_chapters\n \n # Save changes\n save_novels()\n \n return jsonify({\"message\": \"Chapters reordered successfully\"})\n\n@app.route('/api/character-relationship-graph', methods=['POST'])\ndef generate_character_relationship_graph():\n \"\"\"生成角色关系图谱,支持自定义API模型\"\"\"\n try:\n req = request.json or {}\n content = req.get('content', '')\n model = req.get('model', '')\n if not content:\n return jsonify({'error': '没有提供内容'}), 400\n if not model:\n return jsonify({'error': '未选择模型'}), 400\n\n # 优化后的prompt,更简洁且强制输出格式\n prompt = (\n \"分析文本中的人物关系,输出JSON格式:\\n\"\n \"{\\\"nodes\\\":[{\\\"id\\\":\\\"人物名\\\",\\\"name\\\":\\\"人物名\\\"}],\\\"links\\\":[{\\\"source\\\":\\\"人物1\\\",\\\"target\\\":\\\"人物2\\\",\\\"relation\\\":\\\"关系\\\"}]}\\n\"\n \"要求:\\n\"\n \"1. nodes包含所有人物,只需id和name字段\\n\"\n \"2. links包含所有关系,必须有relation字段\\n\"\n \"3. relation必须是简短中文词语\\n\"\n \"4. 只输出JSON,不要其他内容\\n\"\n f\"文本:{content}\"\n )\n\n # 自定义API模型\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({'error': '自定义API配置不存在'}), 404\n\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config.get('path', '')\n model_name = api_config['modelName']\n secret = api_config.get('secret', '')\n\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n api_url = f\"{clean_base_url}/{clean_path}\" if clean_path else clean_base_url\n\n headers = {\"Content-Type\": \"application/json\"}\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n payload = {\n \"model\": model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000, # 增加token限制\n \"temperature\": 0.3 # 降低随机性\n }\n\n try:\n # 记录请求信息\n logging.info(f\"发送请求到自定义API: {api_url}\")\n logging.info(f\"请求头: {headers}\")\n logging.info(f\"请求体: {json.dumps(payload, ensure_ascii=False)}\")\n\n resp = requests.post(api_url, headers=headers, json=payload, timeout=60)\n \n # 记录响应信息\n logging.info(f\"API响应状态码: {resp.status_code}\")\n logging.info(f\"API响应头: {dict(resp.headers)}\")\n logging.info(f\"API响应内容: {resp.text[:1000]}...\") # 只记录前1000个字符\n\n if resp.status_code != 200:\n error_msg = f\"自定义API分析失败,状态码: {resp.status_code}\"\n try:\n error_detail = resp.json().get('error', {}).get('message', resp.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {resp.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = resp.json()\n logging.info(f\"API响应JSON解析成功: {json.dumps(result, ensure_ascii=False)[:200]}...\")\n\n # 兼容OpenAI格式和常见格式\n if 'choices' in result and result['choices']:\n content = result['choices'][0].get('message',{}).get('content','')\n if not content:\n logging.error(\"API响应中没有content字段\")\n return jsonify({'error': 'API响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n logging.info(f\"处理后的content: {content[:200]}...\")\n \n # 检查JSON是否完整\n if not content.strip().endswith('}'):\n logging.error(f\"API返回的JSON不完整: {content}\")\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n try:\n relationship_data = json.loads(content)\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {content}\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n else:\n relationship_data = result\n\n # 验证返回的数据结构\n if not isinstance(relationship_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in relationship_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in relationship_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(relationship_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(relationship_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n\n return jsonify(relationship_data)\n\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {resp.text[:1000]}...\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"处理API响应时出错: {str(e)}, 原始内容: {resp.text[:1000]}...\")\n return jsonify({'error': f'处理API响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n logging.error(f\"请求自定义API时出错: {str(e)}\")\n return jsonify({'error': f'请求自定义API时出错: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"自定义API调用出错: {str(e)}\")\n return jsonify({'error': f'自定义API调用出错: {str(e)}'}), 500\n\n # OpenRouter模型\n else:\n if not OPENROUTER_API_KEY:\n return jsonify({'error': '请先设置OpenRouter API密钥'}), 401\n\n try:\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n },\n json={\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000, # 增加token限制\n \"temperature\": 0.3 # 降低随机性\n }\n )\n\n # 记录响应信息\n logging.info(f\"OpenRouter响应状态码: {response.status_code}\")\n logging.info(f\"OpenRouter响应内容: {response.text[:1000]}...\")\n\n if response.status_code != 200:\n error_msg = f\"AI分析失败,状态码: {response.status_code}\"\n try:\n error_detail = response.json().get('error', {}).get('message', response.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {response.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = response.json()\n content = result['choices'][0]['message']['content']\n if not content:\n logging.error(\"OpenRouter响应中没有content字段\")\n return jsonify({'error': 'OpenRouter响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n logging.info(f\"处理后的content: {content[:200]}...\")\n \n # 检查JSON是否完整\n if not content.strip().endswith('}'):\n logging.error(f\"API返回的JSON不完整: {content}\")\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n relationship_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(relationship_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in relationship_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in relationship_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(relationship_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(relationship_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(relationship_data)\n\n except json.JSONDecodeError as e:\n logging.error(f\"JSON解析错误: {str(e)}, 原始内容: {content}\")\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"处理OpenRouter响应时出错: {str(e)}, 原始内容: {response.text[:1000]}...\")\n return jsonify({'error': f'处理OpenRouter响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n logging.error(f\"请求OpenRouter时出错: {str(e)}\")\n return jsonify({'error': f'请求OpenRouter时出错: {str(e)}'}), 500\n except Exception as e:\n logging.error(f\"OpenRouter调用出错: {str(e)}\")\n return jsonify({'error': f'OpenRouter调用出错: {str(e)}'}), 500\n\n except Exception as e:\n logging.error(f\"生成角色关系图谱时出错: {str(e)}\")\n return jsonify({'error': str(e)}), 500\n\ndef strip_code_block(text):\n \"\"\"\n 去除AI返回内容中的```json、```text等代码块包裹。\n \"\"\"\n import re\n # 匹配```json ... ```或```text ... ```或``` ... ```\n pattern = r\"^```(?:json|text)?\\s*([\\s\\S]*?)\\s*```$\"\n match = re.match(pattern, text.strip(), re.IGNORECASE)\n if match:\n return match.group(1).strip()\n return text.strip()\n\n@app.route('/api/knowledge-graph', methods=['POST'])\ndef generate_knowledge_graph():\n \"\"\"生成知识图谱,支持自定义API模型\"\"\"\n try:\n req = request.json or {}\n content = req.get('content', '')\n model = req.get('model', '')\n if not content:\n return jsonify({'error': '没有提供内容'}), 400\n if not model:\n return jsonify({'error': '未选择模型'}), 400\n\n # 优化后的prompt,更简洁且强制输出格式\n prompt = (\n \"分析文本中的知识点和关系,输出JSON格式:\\n\"\n \"{\\\"nodes\\\":[{\\\"id\\\":\\\"知识点ID\\\",\\\"name\\\":\\\"知识点名称\\\",\\\"category\\\":\\\"分类\\\",\\\"tags\\\":[\\\"标签1\\\",\\\"标签2\\\"]}],\"\n \"\\\"links\\\":[{\\\"source\\\":\\\"知识点1\\\",\\\"target\\\":\\\"知识点2\\\",\\\"relation\\\":\\\"关系\\\",\\\"type\\\":\\\"关系类型\\\"}]}\\n\"\n \"要求:\\n\"\n \"1. nodes包含所有知识点,必须有id、name、category和tags字段\\n\"\n \"2. category必须是以下之一:人物、地点、物品、概念、事件、规则\\n\"\n \"3. tags是相关标签数组,至少包含一个标签\\n\"\n \"4. links包含所有关系,必须有source、target、relation和type字段\\n\"\n \"5. type必须是以下之一:包含、属于、导致、影响、引用\\n\"\n \"6. relation必须是简短中文词语\\n\"\n \"7. 只输出JSON,不要其他内容\\n\"\n f\"文本:{content}\"\n )\n\n # 自定义API模型\n if model.startswith('custom-'):\n api_id = model.replace('custom-', '', 1)\n if api_id not in CUSTOM_API_CONFIGS:\n return jsonify({'error': '自定义API配置不存在'}), 404\n\n api_config = CUSTOM_API_CONFIGS[api_id]\n base_url = api_config['baseUrl']\n path = api_config.get('path', '')\n model_name = api_config['modelName']\n secret = api_config.get('secret', '')\n\n clean_base_url = base_url.rstrip('/')\n clean_path = path.lstrip('/')\n api_url = f\"{clean_base_url}/{clean_path}\" if clean_path else clean_base_url\n\n headers = {\"Content-Type\": \"application/json\"}\n if secret:\n headers[\"Authorization\"] = f\"Bearer {secret}\"\n\n payload = {\n \"model\": model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000,\n \"temperature\": 0.3\n }\n\n try:\n resp = requests.post(api_url, headers=headers, json=payload, timeout=60)\n \n if resp.status_code != 200:\n error_msg = f\"自定义API分析失败,状态码: {resp.status_code}\"\n try:\n error_detail = resp.json().get('error', {}).get('message', resp.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {resp.text[:200]}...\"\n logging.error(error_msg)\n return jsonify({'error': error_msg}), 500\n\n try:\n result = resp.json()\n content = result['choices'][0]['message']['content']\n if not content:\n return jsonify({'error': 'API响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n \n if not content.strip().endswith('}'):\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n knowledge_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(knowledge_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in knowledge_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in knowledge_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(knowledge_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(knowledge_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(knowledge_data)\n\n except json.JSONDecodeError as e:\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'处理API响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n return jsonify({'error': f'请求自定义API时出错: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'自定义API调用出错: {str(e)}'}), 500\n\n # OpenRouter模型\n else:\n if not OPENROUTER_API_KEY:\n return jsonify({'error': '请先设置OpenRouter API密钥'}), 401\n\n try:\n response = requests.post(\n f\"{OPENROUTER_API_BASE}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n \"Content-Type\": \"application/json\"\n },\n json={\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": 2000,\n \"temperature\": 0.3\n }\n )\n\n if response.status_code != 200:\n error_msg = f\"AI分析失败,状态码: {response.status_code}\"\n try:\n error_detail = response.json().get('error', {}).get('message', response.text)\n error_msg += f\", 错误详情: {error_detail}\"\n except:\n error_msg += f\", 响应内容: {response.text[:200]}...\"\n return jsonify({'error': error_msg}), 500\n\n try:\n result = response.json()\n content = result['choices'][0]['message']['content']\n if not content:\n return jsonify({'error': 'OpenRouter响应格式不正确:缺少content字段'}), 500\n \n content = strip_code_block(content)\n \n if not content.strip().endswith('}'):\n return jsonify({'error': 'API返回的数据不完整,请尝试减少文本内容或使用其他模型'}), 500\n \n knowledge_data = json.loads(content)\n \n # 验证返回的数据结构\n if not isinstance(knowledge_data, dict):\n raise ValueError(\"返回的数据不是字典格式\")\n if 'nodes' not in knowledge_data:\n raise ValueError(\"返回的数据缺少nodes字段\")\n if 'links' not in knowledge_data:\n raise ValueError(\"返回的数据缺少links字段\")\n if not isinstance(knowledge_data['nodes'], list):\n raise ValueError(\"nodes字段不是数组格式\")\n if not isinstance(knowledge_data['links'], list):\n raise ValueError(\"links字段不是数组格式\")\n \n return jsonify(knowledge_data)\n\n except json.JSONDecodeError as e:\n return jsonify({'error': f'返回的数据格式不正确: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'处理OpenRouter响应时出错: {str(e)}'}), 500\n\n except requests.exceptions.RequestException as e:\n return jsonify({'error': f'请求OpenRouter时出错: {str(e)}'}), 500\n except Exception as e:\n return jsonify({'error': f'OpenRouter调用出错: {str(e)}'}), 500\n\n except Exception as e:\n return jsonify({'error': str(e)}), 500\n\nif __name__ == '__main__':\n # 确保静态文件夹配置正确\n import webbrowser\n from threading import Timer\n \n # 设置端口\n port = 5000\n \n # 打包后的环境自动启用生产模式\n debug_mode = not getattr(sys, 'frozen', False)\n \n # 添加标志变量防止多次打开浏览器\n browser_opened = False\n \n # 定义一个函数,在应用启动后打开浏览器\n def open_browser():\n global browser_opened\n # 仅在browser_opened为False时打开浏览器\n if not browser_opened:\n webbrowser.open(f'http://127.0.0.1:{port}/')\n browser_opened = True\n print(\"笔墨星河已成功启动!浏览器窗口即将打开。\")\n print(f\"如果没有自动打开,请手动访问: http://127.0.0.1:{port}/\")\n print(\"笔墨星河,本软件免费,请合法合规使用本软件,请勿用作非法用途。软件作者:L.H\") \n # 在新线程中启动浏览器(延迟1.5秒,确保Flask服务器已启动)\n # 增加延迟时间,确保服务器完全启动\n Timer(1.5, open_browser).start()\n \n print(\"正在启动笔墨星河应用...\")\n # 在启动应用前加载数据\n load_data()\n # 启动Flask应用,禁用Flask的自动重载器,这可能导致浏览器被打开两次\n app.run(debug=debug_mode, port=port, use_reloader=False)"}}}}