diff --git a/README.md b/README.md index 6e842d6..b3b4c2c 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,8 @@ piano-plan/ --- -> **版本**:v1.5.6 +> **版本**:v1.5.7 > **创建时间**:2026-04-17 -> **最后更新**:2026-04-30 +> **最后更新**:2026-05-05 > -> **重要更新**:v1.5.6 - Nginx限流(防爬虫);每日自动备份(30天) +> **重要更新**:v1.5.7 - 动态API提供商管理(新增/编辑/删除);OpenCode Go集成(deepseek-v4-pro/qwen3.6-plus);AI生成调试增强 diff --git a/app/config.py b/app/config.py index 731672a..6b2641d 100644 --- a/app/config.py +++ b/app/config.py @@ -5,25 +5,52 @@ import pathlib # 检测是否在Docker容器中 IS_DOCKER = os.environ.get("FLASK_ENV") == "production" +# 默认提供商列表 +DEFAULT_PROVIDERS = { + "minimax": { + "name": "MiniMax", + "endpoint": "https://api.minimaxi.com/anthropic/v1", + "models": ["MiniMax-M2.7-highspeed"], + }, + "volcengine": { + "name": "火山引擎", + "endpoint": "https://ark.cn-beijing.volces.com/api/coding/v3", + "models": ["doubao-seed-2.0-pro", "doubao-seed-code", "doubao-seed-2.0-lite"], + }, + "deepseek": { + "name": "DeepSeek", + "endpoint": "https://api.deepseek.com/v1", + "models": ["deepseek-chat"], + }, + "openai": { + "name": "OpenAI", + "endpoint": "https://api.openai.com/v1", + "models": ["gpt-4o-mini", "gpt-4o"], + }, + "openrouter": { + "name": "OpenRouter", + "endpoint": "https://openrouter.ai/api/v1", + "models": ["anthropic/claude-3-haiku"], + }, + "opencodego": { + "name": "OpenCode Go", + "endpoint": "https://opencode.ai/zen/go/v1", + "models": ["deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus"], + }, +} -def load_api_config(app_config=None): - """加载API配置""" - import json +# 兼容旧配置:从 default_model 迁移到 models +def _migrate_provider_models(providers): + """确保每个 provider 都有 models 数组""" + for pid, pdata in list(providers.items()): + if "models" not in pdata: + if "default_model" in pdata and pdata["default_model"]: + pdata["models"] = [pdata.pop("default_model")] + else: + pdata["models"] = [] + return providers - # 优先从 app_config 获取路径,否则使用基于 __file__ 的绝对路径 - if app_config and app_config.get("API_CONFIG_FILE"): - config_file = pathlib.Path(app_config["API_CONFIG_FILE"]) - else: - config_file = pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json" - - default_config = { - "provider": "volcengine", - "api_key": "", - "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3", - "model": "doubao-seed-2.0-pro", - "temperature": 0.7, - "watermark_text": "", - "prompt_template": """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。 +DEFAULT_PROMPT_TEMPLATE = """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。 ## 学员信息 - 姓名:{student_name} @@ -39,22 +66,56 @@ def load_api_config(app_config=None): 3. 针对每个问题的核心练习建议 4. 重点注意事项 -语言要专业、简洁、有鼓励性。使用Markdown格式。""", +语言要专业、简洁、有鼓励性。使用Markdown格式。""" + + +def _get_config_path(app_config=None): + if app_config and app_config.get("API_CONFIG_FILE"): + return pathlib.Path(app_config["API_CONFIG_FILE"]) + return pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json" + + +def _build_default_config(): + return { + "provider": "volcengine", + "providers": dict(DEFAULT_PROVIDERS), + "api_key": "", + "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3", + "model": "doubao-seed-2.0-pro", + "temperature": 0.7, + "watermark_text": "", + "prompt_template": DEFAULT_PROMPT_TEMPLATE, } + +def load_api_config(app_config=None): + """加载API配置""" + import json + + config_file = _get_config_path(app_config) + default_config = _build_default_config() + if config_file.exists(): try: with open(config_file, "r", encoding="utf-8") as f: loaded_config = json.load(f) + # 确保 providers 字段存在(兼容旧配置) + if "providers" not in loaded_config: + loaded_config["providers"] = dict(DEFAULT_PROVIDERS) + # 迁移旧 default_model → models + loaded_config["providers"] = _migrate_provider_models(loaded_config["providers"]) + # 合并缺失的默认 provider + for pid, pdata in DEFAULT_PROVIDERS.items(): + if pid not in loaded_config["providers"]: + loaded_config["providers"][pid] = pdata # 如果 api_keys 映射存在,根据当前 provider 自动设置 api_key if "api_keys" in loaded_config: provider = loaded_config.get("provider", "volcengine") loaded_config["api_key"] = loaded_config["api_keys"].get(provider, "") return loaded_config - except: + except Exception: pass - # 返回默认配置 return default_config @@ -62,40 +123,38 @@ def save_api_config(config, app_config=None): """保存API配置""" import json - # 优先从 app_config 获取路径,否则使用基于 __file__ 的绝对路径 - if app_config and app_config.get("API_CONFIG_FILE"): - config_file = pathlib.Path(app_config["API_CONFIG_FILE"]) - else: - config_file = pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json" - - config_file = pathlib.Path(config_file) + config_file = _get_config_path(app_config) config_file.parent.mkdir(parents=True, exist_ok=True) - # 先读取现有配置,保留 api_keys 映射 existing_config = {} if config_file.exists(): try: with open(config_file, "r", encoding="utf-8") as f: existing_config = json.load(f) - except: + except Exception: pass - # 如果 config 中有 api_key,保存到 api_keys 映射(保留其他 provider 的 key) + # 确保 providers 存在 + if "providers" not in existing_config: + existing_config["providers"] = dict(DEFAULT_PROVIDERS) + existing_config["providers"] = _migrate_provider_models(existing_config["providers"]) + + # 如果 config 中有 providers,完整替换 + if "providers" in config: + existing_config["providers"] = config["providers"] + + # 保存 api_key 到 api_keys 映射 if "api_key" in config and config["api_key"]: if "api_keys" not in existing_config: existing_config["api_keys"] = {} provider = config.get("provider", "volcengine") existing_config["api_keys"][provider] = config["api_key"] - # 合并配置:保留 api_keys,更新其他字段 - existing_config["provider"] = config.get("provider", existing_config.get("provider", "volcengine")) - existing_config["base_url"] = config.get("base_url", existing_config.get("base_url", "")) - existing_config["model"] = config.get("model", existing_config.get("model", "")) - existing_config["temperature"] = config.get("temperature", existing_config.get("temperature", 0.7)) - existing_config["prompt_template"] = config.get("prompt_template", existing_config.get("prompt_template", "")) - existing_config["watermark_text"] = config.get("watermark_text", existing_config.get("watermark_text", "")) + # 更新当前选中的配置 + for key in ["provider", "base_url", "model", "temperature", "prompt_template", "watermark_text"]: + if key in config: + existing_config[key] = config[key] - # 当前选中的 provider 的 key 直接存储(用于兼容旧逻辑) if config.get("api_key"): existing_config["api_key"] = config["api_key"] diff --git a/app/models.py b/app/models.py index 968347f..5adf183 100644 --- a/app/models.py +++ b/app/models.py @@ -133,7 +133,7 @@ class Student(db.Model): key=lambda p: (severity_order.get(p.severity, 1), p.created_at) ) # 通过关联获取问题名称 - problem_names = [p.problem.name if p.problem else p.problem_name for p in problems_list] + problem_names = [p.problem.name if p.problem else "未知问题" for p in problems_list] # 获取目标统计 goal_count = len(self.goal_records) if self.goal_records else 0 diff --git a/app/routes/plans.py b/app/routes/plans.py index a961b1f..495625b 100644 --- a/app/routes/plans.py +++ b/app/routes/plans.py @@ -467,7 +467,7 @@ def generate_plan(): ) # 真正调用API生成报告 - _, ai_report, error, _ = generate_ai_report( + _, ai_report, error, extra = generate_ai_report( student_name=student.name, wechat_nickname=student.wechat_nickname or "", problems=problem_data, @@ -479,24 +479,46 @@ def generate_plan(): ) if error: + # 拼接详细的错误信息用于显示 + display_error = f"AI生成失败: {error}" + if extra: + debug_info = [] + if extra.get("prompt_tokens"): + debug_info.append(f"输入token={extra['prompt_tokens']}") + if extra.get("completion_tokens"): + debug_info.append(f"输出token={extra['completion_tokens']}") + if extra.get("finish_reason"): + debug_info.append(f"finish={extra['finish_reason']}") + if extra.get("raw_keys"): + debug_info.append(f"keys={extra['raw_keys']}") + if debug_info: + display_error += " [" + ", ".join(debug_info) + "]" yield sse_format( { "step": "ai_error", - "message": f"AI生成失败: {error}", + "message": display_error, "progress": 80, - "error": error, + "error": display_error, } ) - plan_content["ai_report_error"] = error + plan_content["ai_report_error"] = display_error else: # 显示AI返回的报告长度 report_lines = len(ai_report.split("\n")) if ai_report else 0 + detail_parts = [f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines} 行"] + if extra: + if extra.get("prompt_tokens"): + detail_parts.append(f"输入token: {extra['prompt_tokens']}") + if extra.get("completion_tokens"): + detail_parts.append(f"输出token: {extra['completion_tokens']}") + if extra.get("finish_reason"): + detail_parts.append(f"finish_reason: {extra['finish_reason']}") yield sse_format( { "step": "ai_response", "message": f"AI报告已生成", "progress": 75, - "detail": f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines} 行", + "detail": " | ".join(detail_parts), } ) yield sse_format( @@ -558,6 +580,7 @@ def generate_plan(): "ai_report": ai_report, "prompt_length": prompt_length, "ai_report_length": len(ai_report) if ai_report else 0, + "ai_error": plan_content.get("ai_report_error", ""), } ) except Exception as e: diff --git a/app/routes/settings.py b/app/routes/settings.py index d3efdc3..f29a2da 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -6,7 +6,7 @@ from datetime import datetime from flask import request, jsonify, render_template, current_app, session, redirect from app.routes import main_bp from app.models import db, Problem, StudentProblem -from app.config import load_api_config, save_api_config +from app.config import load_api_config, save_api_config, DEFAULT_PROVIDERS from app.routes.auth import login_required_json, admin_required @@ -42,8 +42,113 @@ def get_api_config(): return jsonify(config) +@main_bp.route("/api/config/providers", methods=["GET"]) +@admin_required +def list_providers(): + """列出所有API提供商""" + config = load_api_config(current_app.config) + providers = config.get("providers", {}) + result = {} + for pid, pdata in providers.items(): + result[pid] = { + "name": pdata.get("name", pid), + "endpoint": pdata.get("endpoint", ""), + "models": pdata.get("models", []), + } + return jsonify(result) +@main_bp.route("/api/config/providers", methods=["POST"]) +@admin_required +def add_provider(): + """新增API提供商""" + data = request.get_json() + provider_id = data.get("id", "").strip().lower() + name = data.get("name", "").strip() + endpoint = data.get("endpoint", "").strip() + models = data.get("models", []) + + if not isinstance(models, list): + models = [m.strip() for m in str(models).split(",") if m.strip()] + + if not provider_id or not name: + return jsonify({"error": "提供商ID和名称不能为空"}), 400 + if not endpoint: + return jsonify({"error": "Endpoint不能为空"}), 400 + + config = load_api_config(current_app.config) + providers = config.get("providers", {}) + + if provider_id in providers: + return jsonify({"error": f"提供商 '{provider_id}' 已存在"}), 400 + + providers[provider_id] = { + "name": name, + "endpoint": endpoint, + "models": models, + } + config["providers"] = providers + save_api_config(config, current_app.config) + return jsonify({"message": "添加成功", "id": provider_id}) + + +@main_bp.route("/api/config/providers/", methods=["DELETE"]) +@admin_required +def delete_provider(provider_id): + """删除API提供商""" + provider_id = provider_id.strip().lower() + config = load_api_config(current_app.config) + providers = config.get("providers", {}) + + if provider_id not in providers: + return jsonify({"error": f"提供商 '{provider_id}' 不存在"}), 404 + + # 不允许删除当前选中的 provider + if provider_id == config.get("provider"): + return jsonify({"error": "不能删除当前正在使用的提供商,请先切换到其他提供商"}), 400 + + # 不允许删除到只剩 0 个 + if len(providers) <= 1: + return jsonify({"error": "至少保留一个提供商"}), 400 + + del providers[provider_id] + + # 同时删除 api_keys 中对应的 key + if "api_keys" in config and provider_id in config["api_keys"]: + del config["api_keys"][provider_id] + + config["providers"] = providers + save_api_config(config, current_app.config) + return jsonify({"message": "删除成功"}) + + +@main_bp.route("/api/config/providers/", methods=["PUT"]) +@admin_required +def update_provider(provider_id): + """更新API提供商(名称、endpoint、模型列表)""" + provider_id = provider_id.strip().lower() + data = request.get_json() + config = load_api_config(current_app.config) + providers = config.get("providers", {}) + + if provider_id not in providers: + return jsonify({"error": f"提供商 '{provider_id}' 不存在"}), 404 + + pdata = providers[provider_id] + if "name" in data: + pdata["name"] = data["name"].strip() + if "endpoint" in data: + pdata["endpoint"] = data["endpoint"].strip() + if "models" in data: + if isinstance(data["models"], list): + pdata["models"] = [m.strip() for m in data["models"] if m.strip()] + else: + pdata["models"] = [m.strip() for m in str(data["models"]).split(",") if m.strip()] + + config["providers"] = providers + save_api_config(config, current_app.config) + return jsonify({"message": "更新成功"}) + @main_bp.route("/api/config", methods=["POST"]) @admin_required @@ -51,32 +156,38 @@ def update_api_config(): """更新API配置""" data = request.get_json() - # 验证必填字段 - if not data.get("api_key"): - return jsonify({"error": "API Key不能为空"}), 400 + config = load_api_config(current_app.config) + provider = data.get("provider", config.get("provider", "volcengine")) - # 根据provider设置默认endpoint - provider = data.get("provider", "volcengine") - default_endpoints = { - "minimax": "https://api.minimaxi.com/anthropic/v1", - "volcengine": "https://ark.cn-beijing.volces.com/api/coding/v3", - "deepseek": "https://api.deepseek.com/v1", - "openrouter": "https://openrouter.ai/api/v1", - } - default_endpoint = default_endpoints.get(provider, "https://ark.cn-beijing.volces.com/api/coding/v3") + # api_key 留空时,尝试使用已有 key(当前 provider 的 key) + api_key = data.get("api_key", "") + if not api_key: + existing_key = config.get("api_keys", {}).get(provider, "") + if not existing_key: + return jsonify({"error": "API Key不能为空"}), 400 + api_key = existing_key + providers = config.get("providers", DEFAULT_PROVIDERS) - # 保存配置 - config = { + # 从 providers 列表获取默认 endpoint 和 model + if provider in providers: + default_endpoint = providers[provider].get("endpoint", "") + models = providers[provider].get("models", []) + default_model = models[0] if models else "" + else: + default_endpoint = "" + default_model = "" + + new_config = { "provider": provider, - "api_key": data.get("api_key", ""), + "api_key": api_key, "base_url": data.get("base_url", default_endpoint), - "model": data.get("model", "doubao-seed-2.0-pro"), + "model": data.get("model", default_model), "temperature": float(data.get("temperature", 0.7)), "prompt_template": data.get("prompt_template", ""), "watermark_text": data.get("watermark_text", ""), } - save_api_config(config, current_app.config) + save_api_config(new_config, current_app.config) return jsonify({"message": "配置保存成功"}) @@ -171,7 +282,7 @@ def delete_problem(problem_id): # 检查是否有关联数据 from app.models import StudentProblem - if StudentProblem.query.filter_by(problem_db_id=problem_id).first(): + if StudentProblem.query.filter_by(problem_id=problem_id).first(): return jsonify({"error": "该问题已被学员使用,无法删除"}), 400 db.session.delete(problem) diff --git a/app/services/plan_generator.py b/app/services/plan_generator.py index b5f9e72..e6f183f 100644 --- a/app/services/plan_generator.py +++ b/app/services/plan_generator.py @@ -321,7 +321,7 @@ def generate_ai_report( "model": model, "messages": [{"role": "user", "content": prompt}], "temperature": temperature, - "max_tokens": 2000, + "max_tokens": 4096, } # 确定 endpoint @@ -330,40 +330,71 @@ def generate_ai_report( else: endpoint = f"{base_url}/chat/completions" + # 调试信息 + req_info = {"endpoint": endpoint, "model": model, "provider": provider} + try: response = requests.post( endpoint, headers=headers, json=payload, - timeout=60, + timeout=180, + allow_redirects=False, ) if response.status_code == 200: - result = response.json() + try: + result = response.json() + except Exception as je: + raw_text = response.text[:300] if response.text else "(empty body)" + return prompt, None, f"API返回非JSON [{endpoint}]: {raw_text}", None # MiniMax (Anthropic格式) 返回结构不同 if provider == "minimax": - # MiniMax 返回的 content 可能是 [{"type": "thinking", ...}, {"type": "text", "text": "..."}] content_list = result.get("content", []) content = "" for item in content_list: if item.get("type") == "text": content = item.get("text", "") break + extra = {"finish_reason": "n/a"} else: - content = ( - result.get("choices", [{}])[0].get("message", {}).get("content", "") - ) - return prompt, content, None, None + choice = result.get("choices", [{}])[0] + content = choice.get("message", {}).get("content", "") + finish = choice.get("finish_reason", "missing") + usage = result.get("usage", {}) + # 调试信息 + extra = { + "finish_reason": finish, + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "raw_keys": list(result.keys()), + } + if not content or not content.strip(): + err_detail = "API返回空内容" + if extra.get("finish_reason", "") not in ("", "n/a"): + err_detail += f" (finish_reason={extra['finish_reason']})" + if extra.get("completion_tokens", 0) > 0: + err_detail += f", 有{extra['completion_tokens']}个输出token但content为空" + if extra.get("raw_keys"): + err_detail += f", 响应keys={extra['raw_keys']}" + return prompt, None, err_detail, extra + return prompt, content, None, extra else: - error_msg = f"API错误: {response.status_code}" + error_msg = f"API返回HTTP {response.status_code}" + if response.status_code in (301, 302, 303, 307, 308): + loc = response.headers.get("Location", "unknown") + error_msg += f" 重定向到: {loc}" try: - error_detail = response.json() - error_msg = error_detail.get("error", {}).get("message", error_msg) + error_body = response.json() + error_msg += ": " + error_body.get("error", {}).get("message", str(error_body)[:200]) except: - pass + raw = response.text[:200] if response.text else "(empty)" + error_msg += ": " + raw return prompt, None, error_msg, None except requests.exceptions.Timeout: - return prompt, None, "API请求超时,请稍后重试", None + return prompt, None, "AI响应超时(等待180秒),网络较慢或模型负载高,请重试", None + except requests.exceptions.ConnectionError as e: + return prompt, None, f"无法连接到API服务器,请检查网络或Endpoint配置", None except Exception as e: return prompt, None, f"调用API失败: {str(e)}", None diff --git a/app/templates/api_settings.html b/app/templates/api_settings.html index 6ed6856..d846e3c 100644 --- a/app/templates/api_settings.html +++ b/app/templates/api_settings.html @@ -4,8 +4,11 @@ {% block content %}
-
+
AI API 配置
+
@@ -14,24 +17,21 @@
-
+
- +
+ + +
-
+
- +
+ + +
@@ -77,41 +77,59 @@
+ + +
-
推荐配置
+
已注册提供商
- +
- - - - - + - - - - - - - - - - - - - - - - - - - - - - +
提供商推荐模型Endpoint
ID名称Endpoint模型
MiniMaxMiniMax-M2.7-highspeedhttps://api.minimaxi.com/anthropic/v1
火山方舟doubao-seed-2.0-prohttps://ark.cn-beijing.volces.com/api/coding/v3
DeepSeekdeepseek-chathttps://api.deepseek.com
OpenRouterdeepseek/deepseek-r1https://openrouter.ai/api/v1
@@ -120,126 +138,199 @@ {% block extra_js %} {% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 2a09e6e..ab7bbe3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1158,7 +1158,11 @@ async function generatePlan() { if (data.step === 'complete') { setTimeout(() => { progressModal.hide(); - alert('方案生成成功!'); + if (data.ai_error) { + alert('基础方案已生成,但AI报告失败: ' + data.ai_error); + } else { + alert('方案生成成功!'); + } showStudentDetail(currentStudentId); }, 500); } diff --git a/app/templates/problems.html b/app/templates/problems.html index e5efcda..4c59a5d 100644 --- a/app/templates/problems.html +++ b/app/templates/problems.html @@ -173,7 +173,7 @@