Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/bmxh-001_bmxh1.06.json
T

1 line
221 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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/<novel_id>', 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/<novel_id>', 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/<novel_id>', 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/<novel_id>/export/<file_format>', 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/<novel_id>/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/<character_id>', 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/<character_id>', 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/<character_id>', 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/<entry_id>', 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/<entry_id>', 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/<entry_id>', 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/<novel_id>/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/<style_id>', 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/<style_id>', 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/<style_id>', 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/<novel_id>/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/<game_id>', 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/<game_id>', 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/<game_id>', 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/<api_id>', 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/<api_id>', 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/<novel_id>/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/<novel_id>/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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/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/<novel_id>', 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/<novel_id>', 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/<novel_id>', 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/<novel_id>/export/<file_format>', 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/<novel_id>/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/<character_id>', 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/<character_id>', 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/<character_id>', 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/<entry_id>', 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/<entry_id>', 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/<entry_id>', 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/<novel_id>/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/<style_id>', 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/<style_id>', 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/<style_id>', 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/<novel_id>/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/<game_id>', 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/<game_id>', 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/<game_id>', 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/<api_id>', 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/<api_id>', 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/<novel_id>/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/<novel_id>/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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/chapters/<chapter_id>', 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/<novel_id>/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)"}}}}