docs: v1.5.7 动态API提供商管理,OpenCode Go集成,Bug修复

This commit is contained in:
hmo
2026-05-05 15:53:24 +08:00
parent ea29c77fe3
commit 30fbe92b9a
11 changed files with 594 additions and 231 deletions
+3 -3
View File
@@ -164,8 +164,8 @@ piano-plan/
--- ---
> **版本**v1.5.6 > **版本**v1.5.7
> **创建时间**2026-04-17 > **创建时间**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生成调试增强
+97 -38
View File
@@ -5,25 +5,52 @@ import pathlib
# 检测是否在Docker容器中 # 检测是否在Docker容器中
IS_DOCKER = os.environ.get("FLASK_ENV") == "production" 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): # 兼容旧配置:从 default_model 迁移到 models
"""加载API配置""" def _migrate_provider_models(providers):
import json """确保每个 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__ 的绝对路径 DEFAULT_PROMPT_TEMPLATE = """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。
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": """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。
## 学员信息 ## 学员信息
- 姓名:{student_name} - 姓名:{student_name}
@@ -39,22 +66,56 @@ def load_api_config(app_config=None):
3. 针对每个问题的核心练习建议 3. 针对每个问题的核心练习建议
4. 重点注意事项 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(): if config_file.exists():
try: try:
with open(config_file, "r", encoding="utf-8") as f: with open(config_file, "r", encoding="utf-8") as f:
loaded_config = json.load(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 # 如果 api_keys 映射存在,根据当前 provider 自动设置 api_key
if "api_keys" in loaded_config: if "api_keys" in loaded_config:
provider = loaded_config.get("provider", "volcengine") provider = loaded_config.get("provider", "volcengine")
loaded_config["api_key"] = loaded_config["api_keys"].get(provider, "") loaded_config["api_key"] = loaded_config["api_keys"].get(provider, "")
return loaded_config return loaded_config
except: except Exception:
pass pass
# 返回默认配置
return default_config return default_config
@@ -62,40 +123,38 @@ def save_api_config(config, app_config=None):
"""保存API配置""" """保存API配置"""
import json import json
# 优先从 app_config 获取路径,否则使用基于 __file__ 的绝对路径 config_file = _get_config_path(app_config)
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.parent.mkdir(parents=True, exist_ok=True) config_file.parent.mkdir(parents=True, exist_ok=True)
# 先读取现有配置,保留 api_keys 映射
existing_config = {} existing_config = {}
if config_file.exists(): if config_file.exists():
try: try:
with open(config_file, "r", encoding="utf-8") as f: with open(config_file, "r", encoding="utf-8") as f:
existing_config = json.load(f) existing_config = json.load(f)
except: except Exception:
pass 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_key" in config and config["api_key"]:
if "api_keys" not in existing_config: if "api_keys" not in existing_config:
existing_config["api_keys"] = {} existing_config["api_keys"] = {}
provider = config.get("provider", "volcengine") provider = config.get("provider", "volcengine")
existing_config["api_keys"][provider] = config["api_key"] existing_config["api_keys"][provider] = config["api_key"]
# 合并配置:保留 api_keys,更新其他字段 # 更新当前选中的配置
existing_config["provider"] = config.get("provider", existing_config.get("provider", "volcengine")) for key in ["provider", "base_url", "model", "temperature", "prompt_template", "watermark_text"]:
existing_config["base_url"] = config.get("base_url", existing_config.get("base_url", "")) if key in config:
existing_config["model"] = config.get("model", existing_config.get("model", "")) existing_config[key] = config[key]
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", ""))
# 当前选中的 provider 的 key 直接存储(用于兼容旧逻辑)
if config.get("api_key"): if config.get("api_key"):
existing_config["api_key"] = config["api_key"] existing_config["api_key"] = config["api_key"]
+1 -1
View File
@@ -133,7 +133,7 @@ class Student(db.Model):
key=lambda p: (severity_order.get(p.severity, 1), p.created_at) 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 goal_count = len(self.goal_records) if self.goal_records else 0
+28 -5
View File
@@ -467,7 +467,7 @@ def generate_plan():
) )
# 真正调用API生成报告 # 真正调用API生成报告
_, ai_report, error, _ = generate_ai_report( _, ai_report, error, extra = generate_ai_report(
student_name=student.name, student_name=student.name,
wechat_nickname=student.wechat_nickname or "", wechat_nickname=student.wechat_nickname or "",
problems=problem_data, problems=problem_data,
@@ -479,24 +479,46 @@ def generate_plan():
) )
if error: 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( yield sse_format(
{ {
"step": "ai_error", "step": "ai_error",
"message": f"AI生成失败: {error}", "message": display_error,
"progress": 80, "progress": 80,
"error": error, "error": display_error,
} }
) )
plan_content["ai_report_error"] = error plan_content["ai_report_error"] = display_error
else: else:
# 显示AI返回的报告长度 # 显示AI返回的报告长度
report_lines = len(ai_report.split("\n")) if ai_report else 0 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( yield sse_format(
{ {
"step": "ai_response", "step": "ai_response",
"message": f"AI报告已生成", "message": f"AI报告已生成",
"progress": 75, "progress": 75,
"detail": f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines}", "detail": " | ".join(detail_parts),
} }
) )
yield sse_format( yield sse_format(
@@ -558,6 +580,7 @@ def generate_plan():
"ai_report": ai_report, "ai_report": ai_report,
"prompt_length": prompt_length, "prompt_length": prompt_length,
"ai_report_length": len(ai_report) if ai_report else 0, "ai_report_length": len(ai_report) if ai_report else 0,
"ai_error": plan_content.get("ai_report_error", ""),
} }
) )
except Exception as e: except Exception as e:
+130 -19
View File
@@ -6,7 +6,7 @@ from datetime import datetime
from flask import request, jsonify, render_template, current_app, session, redirect from flask import request, jsonify, render_template, current_app, session, redirect
from app.routes import main_bp from app.routes import main_bp
from app.models import db, Problem, StudentProblem 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 from app.routes.auth import login_required_json, admin_required
@@ -42,8 +42,113 @@ def get_api_config():
return jsonify(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/<provider_id>", 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/<provider_id>", 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"]) @main_bp.route("/api/config", methods=["POST"])
@admin_required @admin_required
@@ -51,32 +156,38 @@ def update_api_config():
"""更新API配置""" """更新API配置"""
data = request.get_json() data = request.get_json()
# 验证必填字段 config = load_api_config(current_app.config)
if not data.get("api_key"): provider = data.get("provider", config.get("provider", "volcengine"))
return jsonify({"error": "API Key不能为空"}), 400
# 根据provider设置默认endpoint # api_key 留空时,尝试使用已有 key(当前 provider 的 key
provider = data.get("provider", "volcengine") api_key = data.get("api_key", "")
default_endpoints = { if not api_key:
"minimax": "https://api.minimaxi.com/anthropic/v1", existing_key = config.get("api_keys", {}).get(provider, "")
"volcengine": "https://ark.cn-beijing.volces.com/api/coding/v3", if not existing_key:
"deepseek": "https://api.deepseek.com/v1", return jsonify({"error": "API Key不能为空"}), 400
"openrouter": "https://openrouter.ai/api/v1", api_key = existing_key
} providers = config.get("providers", DEFAULT_PROVIDERS)
default_endpoint = default_endpoints.get(provider, "https://ark.cn-beijing.volces.com/api/coding/v3")
# 保存配置 # 从 providers 列表获取默认 endpoint 和 model
config = { 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, "provider": provider,
"api_key": data.get("api_key", ""), "api_key": api_key,
"base_url": data.get("base_url", default_endpoint), "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)), "temperature": float(data.get("temperature", 0.7)),
"prompt_template": data.get("prompt_template", ""), "prompt_template": data.get("prompt_template", ""),
"watermark_text": data.get("watermark_text", ""), "watermark_text": data.get("watermark_text", ""),
} }
save_api_config(config, current_app.config) save_api_config(new_config, current_app.config)
return jsonify({"message": "配置保存成功"}) return jsonify({"message": "配置保存成功"})
@@ -171,7 +282,7 @@ def delete_problem(problem_id):
# 检查是否有关联数据 # 检查是否有关联数据
from app.models import StudentProblem 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 return jsonify({"error": "该问题已被学员使用,无法删除"}), 400
db.session.delete(problem) db.session.delete(problem)
+44 -13
View File
@@ -321,7 +321,7 @@ def generate_ai_report(
"model": model, "model": model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"temperature": temperature, "temperature": temperature,
"max_tokens": 2000, "max_tokens": 4096,
} }
# 确定 endpoint # 确定 endpoint
@@ -330,40 +330,71 @@ def generate_ai_report(
else: else:
endpoint = f"{base_url}/chat/completions" endpoint = f"{base_url}/chat/completions"
# 调试信息
req_info = {"endpoint": endpoint, "model": model, "provider": provider}
try: try:
response = requests.post( response = requests.post(
endpoint, endpoint,
headers=headers, headers=headers,
json=payload, json=payload,
timeout=60, timeout=180,
allow_redirects=False,
) )
if response.status_code == 200: 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格式) 返回结构不同 # MiniMax (Anthropic格式) 返回结构不同
if provider == "minimax": if provider == "minimax":
# MiniMax 返回的 content 可能是 [{"type": "thinking", ...}, {"type": "text", "text": "..."}]
content_list = result.get("content", []) content_list = result.get("content", [])
content = "" content = ""
for item in content_list: for item in content_list:
if item.get("type") == "text": if item.get("type") == "text":
content = item.get("text", "") content = item.get("text", "")
break break
extra = {"finish_reason": "n/a"}
else: else:
content = ( choice = result.get("choices", [{}])[0]
result.get("choices", [{}])[0].get("message", {}).get("content", "") content = choice.get("message", {}).get("content", "")
) finish = choice.get("finish_reason", "missing")
return prompt, content, None, None 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: 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: try:
error_detail = response.json() error_body = response.json()
error_msg = error_detail.get("error", {}).get("message", error_msg) error_msg += ": " + error_body.get("error", {}).get("message", str(error_body)[:200])
except: except:
pass raw = response.text[:200] if response.text else "(empty)"
error_msg += ": " + raw
return prompt, None, error_msg, None return prompt, None, error_msg, None
except requests.exceptions.Timeout: 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: except Exception as e:
return prompt, None, f"调用API失败: {str(e)}", None return prompt, None, f"调用API失败: {str(e)}", None
+236 -145
View File
@@ -4,8 +4,11 @@
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-key"></i> AI API 配置</h5> <h5 class="mb-0"><i class="bi bi-key"></i> AI API 配置</h5>
<button class="btn btn-sm btn-outline-primary" onclick="showAddProviderModal()">
<i class="bi bi-plus-lg"></i> 新增提供商
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="alert alert-info"> <div class="alert alert-info">
@@ -14,24 +17,21 @@
<form id="apiConfigForm"> <form id="apiConfigForm">
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-8 mb-3">
<label class="form-label">提供商</label> <label class="form-label">提供商</label>
<select class="form-select" id="apiProvider" onchange="onProviderChange()"> <div class="input-group">
<option value="minimax">MiniMax (Token Plan)</option> <select class="form-select" id="apiProvider" onchange="onProviderChange()"></select>
<option value="volcengine">火山引擎 (Volcengine)</option> <button class="btn btn-outline-danger" type="button" onclick="deleteCurrentProvider()" title="删除当前提供商">
<option value="deepseek">DeepSeek</option> <i class="bi bi-trash"></i>
<option value="openai">OpenAI</option> </button>
<option value="openrouter">OpenRouter</option> </div>
</select>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-4 mb-3">
<label class="form-label">模型</label> <label class="form-label">模型</label>
<select class="form-select" id="apiModel"> <div id="modelInputArea">
<option value="MiniMax-M2.7-highspeed">MiniMax-M2.7-highspeed</option> <select class="form-select" id="apiModelSelect" onchange="onModelSelectChange()" style="display:none"></select>
<option value="doubao-seed-2.0-pro">doubao-seed-2.0-pro</option> <input type="text" class="form-control" id="apiModelText" placeholder="如 gpt-4o-mini">
<option value="doubao-seed-code">doubao-seed-code</option> </div>
<option value="doubao-seed-2.0-lite">doubao-seed-2.0-lite</option>
</select>
</div> </div>
</div> </div>
@@ -77,41 +77,59 @@
</div> </div>
</div> </div>
<!-- 新增提供商 Modal -->
<div class="modal fade" id="addProviderModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="providerModalTitle">新增API提供商</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">提供商ID <small class="text-muted">(英文标识)</small></label>
<input type="text" class="form-control" id="newProviderId" placeholder="如 opencodego">
</div>
<div class="mb-3">
<label class="form-label">显示名称</label>
<input type="text" class="form-control" id="newProviderName" placeholder="如 OpenCode Go">
</div>
<div class="mb-3">
<label class="form-label">API Endpoint</label>
<input type="text" class="form-control" id="newProviderEndpoint" placeholder="https://api.example.com/v1">
</div>
<div class="mb-3">
<label class="form-label">模型列表</label>
<div id="newModelsContainer">
<div class="input-group mb-1">
<input type="text" class="form-control newModelInput" placeholder="模型名称,如 gpt-4o">
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">&times;</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addModelInput()">
<i class="bi bi-plus"></i> 添加模型
</button>
<small class="text-muted d-block">一个提供商可以有多个模型</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="providerModalSaveBtn" onclick="saveProvider()">确认添加</button>
</div>
</div>
</div>
</div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 推荐配置</h6> <h6 class="mb-0"><i class="bi bi-lightbulb"></i> 已注册提供商</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<table class="table table-sm"> <table class="table table-sm" id="providersTable">
<thead> <thead>
<tr> <tr><th>ID</th><th>名称</th><th>Endpoint</th><th>模型</th><th></th></tr>
<th>提供商</th>
<th>推荐模型</th>
<th>Endpoint</th>
</tr>
</thead> </thead>
<tbody> <tbody></tbody>
<tr>
<td>MiniMax</td>
<td>MiniMax-M2.7-highspeed</td>
<td><code>https://api.minimaxi.com/anthropic/v1</code></td>
</tr>
<tr>
<td>火山方舟</td>
<td>doubao-seed-2.0-pro</td>
<td><code>https://ark.cn-beijing.volces.com/api/coding/v3</code></td>
</tr>
<tr>
<td>DeepSeek</td>
<td>deepseek-chat</td>
<td><code>https://api.deepseek.com</code></td>
</tr>
<tr>
<td>OpenRouter</td>
<td>deepseek/deepseek-r1</td>
<td><code>https://openrouter.ai/api/v1</code></td>
</tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
@@ -120,126 +138,199 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
window.pageInit = function(data) { window.pageInit = function(data) {
if (data.role !== 'admin') { if (data.role !== 'admin') { window.location.href = '/'; return; }
window.location.href = '/';
return;
}
loadApiConfig(); loadApiConfig();
}; };
const providerDefaults = { let allProviders = {};
'minimax': { let currentModel = '';
endpoint: 'https://api.minimaxi.com/anthropic/v1',
model: 'MiniMax-M2.7-highspeed',
temperature: 0.7,
api_key: ''
},
'volcengine': {
endpoint: 'https://ark.cn-beijing.volces.com/api/coding/v3',
model: 'doubao-seed-2.0-pro',
temperature: 0.7,
api_key: ''
},
'deepseek': {
endpoint: 'https://api.deepseek.com',
model: 'deepseek-chat',
temperature: 0.7,
api_key: ''
},
'openai': {
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
temperature: 0.7,
api_key: ''
},
'openrouter': {
endpoint: 'https://openrouter.ai/api/v1',
model: 'anthropic/claude-3-haiku',
temperature: 0.7,
api_key: ''
}
};
async function onProviderChange() { async function loadProviders() {
const provider = document.getElementById('apiProvider').value; const r = await fetch('/api/config/providers');
const defaults = providerDefaults[provider] || providerDefaults['volcengine']; allProviders = await r.json();
renderProviderDropdown();
document.getElementById('apiModel').value = defaults.model; renderProvidersTable();
document.getElementById('apiEndpoint').value = defaults.endpoint;
document.getElementById('apiTemperature').value = defaults.temperature;
document.getElementById('apiKey').value = defaults.api_key || '';
} }
async function saveApiConfig(silent = false, overrideProvider = null) { function renderProviderDropdown() {
const provider = overrideProvider || document.getElementById('apiProvider').value; const sel = document.getElementById('apiProvider');
const cur = sel.value;
sel.innerHTML = '';
for (const [id, p] of Object.entries(allProviders)) {
sel.innerHTML += `<option value="${id}">${p.name}</option>`;
}
if (cur && allProviders[cur]) sel.value = cur;
}
function renderProvidersTable() {
const tbody = document.getElementById('providersTable').querySelector('tbody');
tbody.innerHTML = '';
for (const [id, p] of Object.entries(allProviders)) {
const ml = (p.models || []).join(', ');
tbody.innerHTML += `<tr>
<td><code>${id}</code></td><td>${p.name}</td>
<td><code style="font-size:11px">${p.endpoint}</code></td>
<td>${ml || '<span class="text-muted">&mdash;</span>'}</td>
<td><button class="btn btn-sm btn-outline-secondary" onclick="editProvider('${id}')" title="编辑"><i class="bi bi-pencil"></i></button></td>
</tr>`;
}
if (!Object.keys(allProviders).length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center">暂无提供商</td></tr>';
}
}
function renderModelInput(models) {
const sel = document.getElementById('apiModelSelect');
const txt = document.getElementById('apiModelText');
if (models && models.length > 1) {
sel.style.display = ''; txt.style.display = 'none';
sel.innerHTML = '';
models.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); });
if (models.includes(currentModel)) sel.value = currentModel; else sel.value = models[0];
} else {
sel.style.display = 'none'; txt.style.display = '';
txt.value = (models && models.length === 1) ? models[0] : (currentModel || '');
}
}
function getSelectedModel() {
const sel = document.getElementById('apiModelSelect');
return sel.style.display !== 'none' ? sel.value : document.getElementById('apiModelText').value;
}
function onModelSelectChange() {
currentModel = document.getElementById('apiModelSelect').value;
}
function onProviderChange() {
const id = document.getElementById('apiProvider').value;
const p = allProviders[id];
if (p) {
document.getElementById('apiEndpoint').value = p.endpoint || '';
renderModelInput(p.models || []);
}
}
async function loadApiConfig() {
await loadProviders();
try {
const r = await fetch('/api/config');
const c = await r.json();
document.getElementById('apiProvider').value = c.provider || '';
document.getElementById('apiEndpoint').value = c.base_url || '';
document.getElementById('apiTemperature').value = c.temperature || 0.7;
document.getElementById('watermarkText').value = c.watermark_text || '';
currentModel = c.model || '';
const p = allProviders[c.provider];
renderModelInput(p ? p.models : []);
if (c.api_key_preview) document.getElementById('apiKeyPreview').textContent = '当前: ' + c.api_key_preview;
} catch (e) { console.error(e); }
}
async function saveApiConfig() {
const m = getSelectedModel();
if (!m) { alert('请选择或输入模型'); return; }
const config = { const config = {
provider: provider, provider: document.getElementById('apiProvider').value,
model: document.getElementById('apiModel').value, model: m,
api_key: document.getElementById('apiKey').value, api_key: document.getElementById('apiKey').value,
base_url: document.getElementById('apiEndpoint').value, base_url: document.getElementById('apiEndpoint').value,
temperature: parseFloat(document.getElementById('apiTemperature').value), temperature: parseFloat(document.getElementById('apiTemperature').value),
watermark_text: document.getElementById('watermarkText').value.trim() watermark_text: document.getElementById('watermarkText').value.trim()
}; };
const r = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) });
const r = await fetch('/api/config', { const d = await r.json();
method: 'POST', if (d.error) alert(d.error); else { alert('保存成功'); loadApiConfig(); }
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await r.json();
if (!silent) {
if (data.error) {
alert(data.error);
} else {
alert('保存成功');
}
}
return data;
}
async function loadApiConfig() {
try {
const r = await fetch('/api/config');
const config = await r.json();
document.getElementById('apiProvider').value = config.provider || 'volcengine';
document.getElementById('apiModel').value = config.model || 'doubao-seed-2.0-pro';
document.getElementById('apiKey').value = config.api_key || '';
document.getElementById('apiEndpoint').value = config.base_url || '';
document.getElementById('apiTemperature').value = config.temperature || 0.7;
document.getElementById('watermarkText').value = config.watermark_text || '';
if (config.api_key_preview) {
document.getElementById('apiKeyPreview').textContent = '当前: ' + config.api_key_preview;
}
} catch (e) {
console.error(e);
}
} }
async function testApiConnection() { async function testApiConnection() {
const resultDiv = document.getElementById('apiTestResult'); const div = document.getElementById('apiTestResult');
resultDiv.innerHTML = '<div class="alert alert-info">测试中...</div>'; div.innerHTML = '<div class="alert alert-info">测试中...</div>';
const r = await fetch('/api/config/test', { method: 'POST' }); const r = await fetch('/api/config/test', { method: 'POST' });
const data = await r.json(); const d = await r.json();
div.innerHTML = d.success ? '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>'
: '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (d.error || '未知错误') + '</div>';
}
if (data.success) { // --- 新增/编辑提供商 ---
resultDiv.innerHTML = '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>'; let editingProviderId = null;
} else {
resultDiv.innerHTML = '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (data.error || '未知错误') + '</div>'; function showAddProviderModal() {
} editingProviderId = null;
document.getElementById('providerModalTitle').textContent = '新增API提供商';
document.getElementById('providerModalSaveBtn').textContent = '确认添加';
document.getElementById('newProviderId').value = '';
document.getElementById('newProviderId').disabled = false;
document.getElementById('newProviderName').value = '';
document.getElementById('newProviderEndpoint').value = '';
document.getElementById('newModelsContainer').innerHTML = `<div class="input-group mb-1">
<input type="text" class="form-control newModelInput" placeholder="模型名称">
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">&times;</button>
</div>`;
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
}
function editProvider(id) {
editingProviderId = id;
const p = allProviders[id];
document.getElementById('providerModalTitle').textContent = '编辑提供商 - ' + p.name;
document.getElementById('providerModalSaveBtn').textContent = '保存修改';
document.getElementById('newProviderId').value = id;
document.getElementById('newProviderId').disabled = true;
document.getElementById('newProviderName').value = p.name || '';
document.getElementById('newProviderEndpoint').value = p.endpoint || '';
const container = document.getElementById('newModelsContainer');
const models = p.models || [];
if (!models.length) models.push('');
container.innerHTML = models.map(m => `<div class="input-group mb-1">
<input type="text" class="form-control newModelInput" value="${m}" placeholder="模型名称">
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">&times;</button>
</div>`).join('');
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
}
async function saveProvider() {
const id = document.getElementById('newProviderId').value.trim().toLowerCase();
const name = document.getElementById('newProviderName').value.trim();
const endpoint = document.getElementById('newProviderEndpoint').value.trim();
const models = []; document.querySelectorAll('.newModelInput').forEach(inp => { const v = inp.value.trim(); if (v) models.push(v); });
if (!name || !endpoint) { alert('请填写名称和Endpoint'); return; }
if (!models.length) { alert('请至少添加一个模型'); return; }
const isEdit = !!editingProviderId;
const url = isEdit ? '/api/config/providers/' + editingProviderId : '/api/config/providers';
const method = isEdit ? 'PUT' : 'POST';
const body = isEdit ? { name, endpoint, models } : { id, name, endpoint, models };
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (d.error) { alert(d.error); return; }
bootstrap.Modal.getInstance(document.getElementById('addProviderModal')).hide();
await loadProviders();
document.getElementById('apiProvider').value = editingProviderId || id;
onProviderChange();
alert(isEdit ? '修改成功' : '添加成功');
}
// --- 删除提供商 ---
async function deleteCurrentProvider() {
const id = document.getElementById('apiProvider').value;
if (Object.keys(allProviders).length <= 1) { alert('至少保留一个提供商'); return; }
if (!confirm('确定要删除提供商 "' + (allProviders[id]?.name || id) + '" 吗?')) return;
const r = await fetch('/api/config/providers/' + id, { method: 'DELETE' });
const d = await r.json();
if (d.error) { alert(d.error); return; }
alert('删除成功');
loadApiConfig();
} }
function toggleApiKey() { function toggleApiKey() {
const input = document.getElementById('apiKey'); const inp = document.getElementById('apiKey');
const icon = document.getElementById('apiKeyToggleIcon'); const ico = document.getElementById('apiKeyToggleIcon');
if (input.type === 'password') { if (inp.type === 'password') { inp.type = 'text'; ico.className = 'bi bi-eye-slash'; }
input.type = 'text'; else { inp.type = 'password'; ico.className = 'bi bi-eye'; }
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
} }
</script> </script>
{% endblock %} {% endblock %}
+5 -1
View File
@@ -1158,7 +1158,11 @@ async function generatePlan() {
if (data.step === 'complete') { if (data.step === 'complete') {
setTimeout(() => { setTimeout(() => {
progressModal.hide(); progressModal.hide();
alert('方案生成成功!'); if (data.ai_error) {
alert('基础方案已生成,但AI报告失败: ' + data.ai_error);
} else {
alert('方案生成成功!');
}
showStudentDetail(currentStudentId); showStudentDetail(currentStudentId);
}, 500); }, 500);
} }
+1 -1
View File
@@ -173,7 +173,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>确定要删除问题 <strong id="deleteProblemName"></strong> 吗?</p> <p>确定要删除问题 <strong id="deleteProblemName"></strong> 吗?</p>
<p class="text-muted small">删除后可在 bk 文件夹中找到备份</p> <p class="text-muted small">已被学员使用的问题无法删除</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
+5 -1
View File
@@ -943,7 +943,11 @@ async function startGeneratePlan() {
generateModal.hide(); generateModal.hide();
startBtn.disabled = false; startBtn.disabled = false;
startBtn.textContent = '开始生成'; startBtn.textContent = '开始生成';
alert('方案生成成功!'); if (data.ai_error) {
alert('基础方案已生成,但AI报告失败: ' + data.ai_error);
} else {
alert('方案生成成功!');
}
loadPlans(); loadPlans();
}, 500); }, 500);
} }
+44 -4
View File
@@ -1,11 +1,50 @@
# 钢琴练习方案系统 - 部署 SOP # 钢琴练习方案系统 - 部署 SOP
> 版本:v1.5.6 > 版本:v1.5.7
> 日期:2026-04-30 > 日期:2026-05-05
> 核心原则:**不删除,只备份后新增/替换** > 核心原则:**不删除,只备份后新增/替换**
--- ---
## 重要更新(v1.5.7
### 🔧 动态API提供商管理
新增 API 提供商的新增、编辑、删除功能,替代原有的硬编码列表:
**功能特点:**
- 新增提供商:填写ID/名称/Endpoint/模型列表
- 编辑提供商:修改名称/Endpoint/模型列表(ID不可改)
- 删除提供商:移除不再使用的提供商
- 多模型支持:每个提供商可配置多个模型(>1个显示下拉框)
**OpenCode Go 预置配置:**
- Endpoint`https://opencode.ai/zen/go/v1`
- 模型:`deepseek-v4-pro``deepseek-v4-flash``qwen3.6-plus`
**API 接口:**
- `GET /api/config/providers` - 列出所有提供商
- `POST /api/config/providers` - 新增提供商
- `PUT /api/config/providers/<id>` - 编辑提供商
- `DELETE /api/config/providers/<id>` - 删除提供商
### 🐛 Bug 修复
- 问题删除字段名错误(`problem_db_id``problem_id`
- 学员模型回退属性不存在(`problem_name` → "未知问题"
- 删除问题提示文案过时
- API Key 留空时保存报错(已有 key 则复用)
- AI 失败时仍弹"方案生成成功"
- API 超时 60s → 180smax_tokens 2000 → 4096
### 📊 调试增强
- AI 生成日志显示 finish_reason、输入/输出 token 数
- API 返回非 JSON 时显示原始内容和请求 URL
- API 返回空内容时显示详细诊断信息
---
## 重要更新(v1.5.6 ## 重要更新(v1.5.6
### 🔒 安全防护:Nginx 限流配置 ### 🔒 安全防护:Nginx 限流配置
@@ -655,6 +694,7 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
| 版本 | 日期 | 变更 | | 版本 | 日期 | 变更 |
|------|------|------| |------|------|------|
| v1.5.7 | 2026-05-05 | 动态API提供商管理(新增/编辑/删除/多模型);OpenCode Go集成;Bug修复(问题删除、空key保存、AI失败弹窗);调试增强 |
| v1.5.6 | 2026-04-30 | Nginx限流配置(防爬虫/恶意请求);每日自动备份(30天保留) | | v1.5.6 | 2026-04-30 | Nginx限流配置(防爬虫/恶意请求);每日自动备份(30天保留) |
| v1.5.5 | 2026-04-28 | 修复容器时区(TZ=Asia/Shanghai);学员列表"x个方案"可点击跳转最新方案详情 | | v1.5.5 | 2026-04-28 | 修复容器时区(TZ=Asia/Shanghai);学员列表"x个方案"可点击跳转最新方案详情 |
| v1.5.4 | 2026-04-28 | PDF正文字体12pt、表格11pt | | v1.5.4 | 2026-04-28 | PDF正文字体12pt、表格11pt |
@@ -725,5 +765,5 @@ docker start piano-plan
--- ---
> **最后更新**2026-04-30 > **最后更新**2026-05-05
> **更新原因**v1.5.6 - Nginx限流配置;每日自动备份 > **更新原因**v1.5.7 - 动态API提供商管理;OpenCode Go集成;Bug修复