Files
Pro_llm_correct/web_app.py
Eric-Terminal 043d2c6890 更新WebUI
2025-10-22 13:50:25 +08:00

1318 lines
56 KiB
Python
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.
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from flask import (
Flask,
abort,
jsonify,
render_template_string,
request,
send_from_directory,
)
from werkzeug.utils import secure_filename
from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE, check_for_updates
from config_manager import ConfigManager
from version import CURRENT_VERSION
DEFAULT_OUTPUT_DIR_NAME = "output_reports"
def _ensure_directory(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def _as_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _usage_snapshot(raw: Optional[Dict[str, Any]]) -> Dict[str, int]:
raw = raw or {}
return {
"prompt_tokens": int(raw.get("prompt_tokens", 0) or 0),
"completion_tokens": int(raw.get("completion_tokens", 0) or 0),
}
def create_app(config_manager: ConfigManager) -> Flask:
"""Create and configure the Flask web application."""
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB payload ceiling
api_service = ApiService(config_manager)
config_lock = threading.Lock()
update_state: Dict[str, Optional[str]] = {"latest": None, "checked": None}
update_lock = threading.Lock()
run_states: Dict[str, Dict[str, Any]] = {}
run_states_lock = threading.Lock()
def get_output_root() -> Path:
configured = config_manager.get("OutputDirectory")
base_path = Path(configured) if configured else Path(DEFAULT_OUTPUT_DIR_NAME)
if not base_path.is_absolute():
base_path = Path.cwd() / base_path
return _ensure_directory(base_path)
def relative_to_output(path: Path) -> str:
root = get_output_root().resolve()
resolved = path.resolve()
try:
relative = resolved.relative_to(root)
except ValueError as exc: # pragma: no cover - safety guard
raise ValueError("Requested path is outside of the output directory") from exc
return relative.as_posix()
def start_update_check(force: bool = False) -> None:
if not _as_bool(config_manager.get("AutoUpdateCheck", True), True):
return
with update_lock:
already_checked = update_state["checked"]
if already_checked and not force:
return
def _worker() -> None:
latest = check_for_updates(CURRENT_VERSION)
timestamp = datetime.now().isoformat(timespec="seconds")
with update_lock:
update_state["latest"] = latest
update_state["checked"] = timestamp
threading.Thread(target=_worker, daemon=True).start()
def _execute_run(
run_id: str,
saved_files: List[Dict[str, Any]],
topic: str,
run_dir: Path,
max_workers: int,
save_markdown: bool,
) -> None:
aggregate = {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0}
failures = 0
with run_states_lock:
state = run_states.get(run_id)
if state:
state["status"] = "running"
def process_single(file_info: Dict[str, Any]) -> Dict[str, Any]:
saved_path: Path = file_info["path"]
logs: List[str] = [f"开始处理: {file_info['original']}"]
markdown_path: Optional[Path] = None
html_path: Optional[Path] = None
vlm_usage = {"prompt_tokens": 0, "completion_tokens": 0}
llm_usage = {"prompt_tokens": 0, "completion_tokens": 0}
error: Optional[str] = None
rendered_html_path: Optional[str] = None
report_markdown_path = saved_path.parent / f"{saved_path.stem}_report.md"
try:
final_report, raw_vlm_usage, raw_llm_usage, rendered_html_path = api_service.process_essay_image(
str(saved_path),
topic,
)
vlm_usage = _usage_snapshot(raw_vlm_usage)
llm_usage = _usage_snapshot(raw_llm_usage)
if save_markdown:
markdown_path = report_markdown_path
markdown_path.write_text(final_report, encoding="utf-8")
logs.append(f"已生成 Markdown: {markdown_path.name}")
render_html = _as_bool(config_manager.get("RenderMarkdown", True), True)
if rendered_html_path:
html_path = Path(rendered_html_path)
logs.append(f"已生成 HTML: {html_path.name}")
if not save_markdown and render_html and report_markdown_path.exists():
report_markdown_path.unlink(missing_ok=True)
logs.append("已删除 Markdown仅保留 HTML")
elif not save_markdown and report_markdown_path.exists():
report_markdown_path.unlink(missing_ok=True)
with config_lock:
config_manager.update_token_usage(
vlm_usage["prompt_tokens"],
vlm_usage["completion_tokens"],
llm_usage["prompt_tokens"],
llm_usage["completion_tokens"],
)
config_manager.save()
except Exception as exc: # pylint: disable=broad-except
logging.exception("文件处理失败: %s", saved_path)
error = str(exc)
logs.append(f"处理失败: {error}")
saved_rel = relative_to_output(saved_path)
markdown_rel = relative_to_output(markdown_path) if markdown_path else None
if rendered_html_path:
html_rel = relative_to_output(Path(rendered_html_path))
else:
html_rel = relative_to_output(html_path) if html_path else None
return {
"index": file_info["index"],
"original": file_info["original"],
"saved": saved_rel,
"markdown": markdown_rel,
"html": html_rel,
"vlm_usage": vlm_usage,
"llm_usage": llm_usage,
"logs": logs,
"error": error,
}
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(process_single, info) for info in saved_files]
for future in as_completed(futures):
result = future.result()
if not result["error"]:
aggregate["vlm_in"] += result["vlm_usage"]["prompt_tokens"]
aggregate["vlm_out"] += result["vlm_usage"]["completion_tokens"]
aggregate["llm_in"] += result["llm_usage"]["prompt_tokens"]
aggregate["llm_out"] += result["llm_usage"]["completion_tokens"]
else:
failures += 1
with run_states_lock:
state = run_states.get(run_id)
if not state:
continue
state["completed"] = state.get("completed", 0) + 1
state.setdefault("results", {})[result["index"]] = result
state["aggregate"] = aggregate.copy()
if result["error"]:
state.setdefault("errors", []).append(
{"index": result["index"], "message": result["error"]}
)
except Exception as exc: # pylint: disable=broad-except
logging.exception("批处理任务失败: %s", run_id)
with run_states_lock:
state = run_states.get(run_id)
if state:
state["status"] = "failed"
state["error"] = str(exc)
state["aggregate"] = aggregate
state["finished_at"] = datetime.now().isoformat(timespec="seconds")
return
total = len(saved_files)
if total == 0:
status = "empty"
elif failures == 0:
status = "ok"
elif failures == total:
status = "failed"
else:
status = "partial"
with run_states_lock:
state = run_states.get(run_id)
if state:
state["status"] = status
state["aggregate"] = aggregate
state["completed"] = total
state["finished_at"] = datetime.now().isoformat(timespec="seconds")
@app.get("/api/config")
def read_config():
usage = {
"vlm_input": int(config_manager.get("UsageVlmInput", 0) or 0),
"vlm_output": int(config_manager.get("UsageVlmOutput", 0) or 0),
"llm_input": int(config_manager.get("UsageLlmInput", 0) or 0),
"llm_output": int(config_manager.get("UsageLlmOutput", 0) or 0),
}
with update_lock:
latest_version = update_state["latest"]
checked_at = update_state["checked"]
has_vlm_key = bool(config_manager.get("VlmApiKey"))
has_llm_key = bool(config_manager.get("LlmApiKey"))
data = {
"VlmUrl": config_manager.get("VlmUrl", ""),
"VlmApiKey": "",
"HasVlmApiKey": has_vlm_key,
"VlmModel": config_manager.get("VlmModel", ""),
"LlmUrl": config_manager.get("LlmUrl", ""),
"LlmApiKey": "",
"HasLlmApiKey": has_llm_key,
"LlmModel": config_manager.get("LlmModel", ""),
"SensitivityFactor": config_manager.get("SensitivityFactor", "1.0"),
"MaxWorkers": config_manager.get("MaxWorkers", 4),
"MaxRetries": config_manager.get("MaxRetries", 3),
"RetryDelay": config_manager.get("RetryDelay", 5),
"SaveMarkdown": _as_bool(config_manager.get("SaveMarkdown", True), True),
"RenderMarkdown": _as_bool(config_manager.get("RenderMarkdown", True), True),
"AutoUpdateCheck": _as_bool(config_manager.get("AutoUpdateCheck", True), True),
"LlmPromptTemplate": config_manager.get("LlmPromptTemplate") or DEFAULT_LLM_PROMPT_TEMPLATE,
"OutputDirectory": str(config_manager.get("OutputDirectory", DEFAULT_OUTPUT_DIR_NAME)),
"Usage": usage,
"CurrentVersion": CURRENT_VERSION,
"LatestVersion": latest_version,
"CheckedAt": checked_at,
}
return jsonify(data)
@app.post("/api/config")
def update_config():
payload = request.get_json(silent=True) or {}
string_fields = [
"VlmUrl",
"VlmModel",
"LlmUrl",
"LlmModel",
"OutputDirectory",
]
sensitive_fields = [
"VlmApiKey",
"LlmApiKey",
]
int_fields = ["MaxWorkers", "MaxRetries", "RetryDelay"]
bool_fields = ["SaveMarkdown", "RenderMarkdown", "AutoUpdateCheck"]
updates: Dict[str, Any] = {}
for key in string_fields:
if key in payload:
value = (payload.get(key) or "").strip()
if key == "OutputDirectory" and not value:
return jsonify({"error": "输出目录不能为空"}), 400
updates[key] = value
for key in sensitive_fields:
if key in payload:
value = (payload.get(key) or "").strip()
if value:
updates[key] = value
if payload.get("ClearVlmApiKey"):
updates["VlmApiKey"] = ""
if payload.get("ClearLlmApiKey"):
updates["LlmApiKey"] = ""
for key in int_fields:
if key in payload and payload[key] not in (None, ""):
try:
updates[key] = int(payload[key])
except (TypeError, ValueError):
return jsonify({"error": f"{key} 需要是整数"}), 400
if "SensitivityFactor" in payload and payload["SensitivityFactor"] not in (None, ""):
try:
updates["SensitivityFactor"] = float(payload["SensitivityFactor"])
except (TypeError, ValueError):
return jsonify({"error": "SensitivityFactor 需要是数字"}), 400
for key in bool_fields:
if key in payload:
updates[key] = bool(payload[key])
prompt_template = payload.get("LlmPromptTemplate")
if prompt_template is not None:
normalized = str(prompt_template).strip()
if not normalized:
updates["LlmPromptTemplate"] = None
elif normalized == DEFAULT_LLM_PROMPT_TEMPLATE.strip():
updates["LlmPromptTemplate"] = None
else:
updates["LlmPromptTemplate"] = normalized
with config_lock:
for key, value in updates.items():
if key == "LlmPromptTemplate" and value is None:
config_manager.config.pop(key, None)
elif key in ("VlmApiKey", "LlmApiKey") and value == "":
config_manager.config.pop(key, None)
else:
config_manager.set(key, value)
config_manager.save()
if "OutputDirectory" in updates and updates["OutputDirectory"]:
get_output_root()
start_update_check(force=True)
return jsonify({"status": "ok"})
@app.post("/api/process")
def process_files():
topic = (request.form.get("topic") or "").strip()
if not topic:
return jsonify({"error": "请输入作文题目"}), 400
uploads = request.files.getlist("files")
if not uploads:
return jsonify({"error": "请至少选择一张图片"}), 400
run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
output_root = get_output_root()
run_dir = _ensure_directory(output_root / run_id)
saved_files: List[Dict[str, Any]] = []
used_names = set()
for index, upload in enumerate(uploads):
original_name = upload.filename or f"upload_{index + 1}.png"
safe_name = secure_filename(original_name) or f"upload_{index + 1}.png"
if safe_name in used_names:
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix or ".png"
counter = 1
candidate = f"{stem}_{counter}{suffix}"
while candidate in used_names:
counter += 1
candidate = f"{stem}_{counter}{suffix}"
safe_name = candidate
used_names.add(safe_name)
saved_path = run_dir / safe_name
upload.save(saved_path)
saved_files.append(
{
"index": index,
"original": original_name,
"name": safe_name,
"path": saved_path,
}
)
try:
max_workers = int(config_manager.get("MaxWorkers", 4)) or 1
except (TypeError, ValueError):
max_workers = 4
save_markdown = _as_bool(config_manager.get("SaveMarkdown", True), True)
run_path = relative_to_output(run_dir)
run_state = {
"run_id": run_id,
"status": "queued",
"total": len(saved_files),
"completed": 0,
"aggregate": {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0},
"results": {},
"errors": [],
"run_path": run_path,
"created_at": datetime.now().isoformat(timespec="seconds"),
}
with run_states_lock:
run_states[run_id] = run_state
worker = threading.Thread(
target=_execute_run,
args=(run_id, saved_files, topic, run_dir, max_workers, save_markdown),
daemon=True,
)
worker.start()
return jsonify(
{
"status": "queued",
"run_id": run_id,
"total": len(saved_files),
"run_path": run_path,
}
)
@app.get("/api/run-status/<run_id>")
def run_status(run_id: str):
with run_states_lock:
state = run_states.get(run_id)
if not state:
abort(404)
results_dict = state.get("results", {})
results = [results_dict[index] for index in sorted(results_dict.keys())]
aggregate = dict(state.get("aggregate", {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0}))
response = {
"run_id": run_id,
"status": state.get("status", "unknown"),
"total": state.get("total", 0),
"completed": state.get("completed", 0),
"aggregate": aggregate,
"results": results,
"run_path": state.get("run_path"),
"error": state.get("error"),
"errors": state.get("errors", []),
}
return jsonify(response)
@app.get("/outputs/<path:requested_path>")
def serve_outputs(requested_path: str):
output_root = get_output_root().resolve()
target_path = (output_root / requested_path).resolve()
try:
target_path.relative_to(output_root)
except ValueError:
abort(404)
if not target_path.exists() or target_path.is_dir():
abort(404)
relative = target_path.relative_to(output_root).as_posix()
return send_from_directory(str(output_root), relative)
@app.get("/api/update-status")
def update_status():
with update_lock:
return jsonify(
{
"current": CURRENT_VERSION,
"latest": update_state["latest"],
"checked": update_state["checked"],
}
)
@app.post("/api/update-check")
def trigger_update_check():
start_update_check(force=True)
return jsonify({"status": "checking"})
@app.get("/")
def index():
html = '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI 作文批改助手 · Web</title>
<style>
:root {
color-scheme: light;
--glass-bg: rgba(255, 255, 255, 0.28);
--glass-border: rgba(255, 255, 255, 0.45);
--text-dark: #101418;
--muted: rgba(16, 20, 24, 0.55);
--accent: rgba(52, 120, 246, 0.9);
--accent-strong: #0b61ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #8ec5fc 0%, #e0c3fc 100%);
padding: 40px 16px;
}
.container {
width: min(1180px, 100%);
background: var(--glass-bg);
border-radius: 28px;
padding: 36px;
box-shadow: 0 25px 70px rgba(31, 38, 135, 0.25);
backdrop-filter: blur(26px);
border: 1px solid var(--glass-border);
color: var(--text-dark);
display: grid;
gap: 28px;
}
h1 {
margin: 0;
font-size: 30px;
font-weight: 600;
}
h2 {
margin: 0 0 18px;
font-size: 22px;
font-weight: 600;
}
p {
margin: 0;
color: var(--muted);
}
.top-bar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.title-block {
display: grid;
gap: 8px;
}
.pill {
padding: 7px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(12px);
font-size: 13px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.muted {
color: var(--muted);
font-size: 13px;
}
.nav {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.nav button {
border: none;
border-radius: 16px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.55);
color: var(--text-dark);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.nav button.active {
background: var(--accent);
color: #fff;
box-shadow: 0 12px 22px rgba(52, 120, 246, 0.25);
}
.nav button:hover {
transform: translateY(-1px);
}
.view {
display: none;
gap: 28px;
}
.view.active {
display: grid;
}
.section {
display: grid;
gap: 20px;
}
form {
display: grid;
gap: 18px;
background: rgba(255, 255, 255, 0.45);
border-radius: 24px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
}
.grid-2 {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: rgba(16, 20, 24, 0.85);
}
input[type="text"],
input[type="number"],
textarea {
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.6);
padding: 12px 14px;
font-size: 15px;
background: rgba(255, 255, 255, 0.65);
color: var(--text-dark);
outline: none;
transition: border 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 120, 246, 0.15);
}
textarea {
min-height: 120px;
resize: vertical;
}
.checkbox-row {
display: flex;
gap: 12px;
align-items: center;
}
.checkbox-row input {
width: 18px;
height: 18px;
}
.actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 14px;
padding: 12px 22px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
background: rgba(52, 120, 246, 0.9);
color: #fff;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 12px 22px rgba(52, 120, 246, 0.25);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
.ghost-btn {
background: transparent;
color: var(--text-dark);
border: 1px solid rgba(255, 255, 255, 0.55);
}
.results {
display: grid;
gap: 18px;
}
.result-card {
border-radius: 20px;
padding: 20px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.45);
display: grid;
gap: 12px;
}
.result-card.error {
border-color: rgba(255, 99, 132, 0.45);
background: rgba(255, 245, 247, 0.75);
}
.result-card.success {
border-color: rgba(72, 199, 142, 0.45);
}
.result-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.result-links {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.result-links a {
font-size: 13px;
font-weight: 600;
color: var(--accent-strong);
text-decoration: none;
}
.logs {
font-size: 13px;
color: rgba(16, 20, 24, 0.7);
line-height: 1.5;
}
.banner {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.45);
font-size: 14px;
}
.about-card {
border-radius: 20px;
padding: 24px;
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(255, 255, 255, 0.45);
backdrop-filter: blur(18px);
display: grid;
gap: 16px;
line-height: 1.6;
}
ul {
margin: 0;
padding-left: 20px;
color: var(--muted);
}
code {
background: rgba(16, 20, 24, 0.08);
border-radius: 6px;
padding: 2px 6px;
font-size: 13px;
}
#toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 14px 18px;
border-radius: 14px;
background: rgba(16, 20, 24, 0.85);
color: #fff;
font-size: 14px;
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#toast.show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 820px) {
.container {
padding: 26px;
}
form {
padding: 20px;
}
.result-header {
flex-direction: column;
align-items: flex-start;
}
.nav button {
flex: 1 1 120px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="top-bar">
<div class="title-block">
<h1>AI 作文批改助手 · Web</h1>
<p id="version-info">版本 {{ current_version }}</p>
</div>
<div class="pill" id="usage-pill">加载用量中...</div>
</div>
<div class="nav">
<button class="nav-btn active" data-view="grading">批改作文</button>
<button class="nav-btn" data-view="settings">服务设置</button>
<button class="nav-btn" data-view="about">关于</button>
</div>
<div class="view active" data-view-section="grading">
<div class="section">
<h2>批改任务</h2>
<form id="process-form" enctype="multipart/form-data">
<label>作文题目 / 场景说明
<textarea name="topic" placeholder="请粘贴题目或场景描述"></textarea>
</label>
<label>上传作文照片 (支持多选)
<input type="file" name="files" accept="image/*" multiple />
</label>
<div class="actions">
<button type="submit" id="start-process">开始批改</button>
</div>
</form>
<div id="process-status"></div>
</div>
<div class="section">
<h2>批改结果</h2>
<div id="results" class="results">
<div class="banner">暂时没有任务,上传图片后将显示处理结果。</div>
</div>
</div>
</div>
<div class="view" data-view-section="settings">
<div class="section">
<h2>服务设置</h2>
<form id="settings-form">
<div class="grid-2">
<label>VLM URL
<input type="text" name="vlm_url" autocomplete="off" />
</label>
<label>VLM API Key
<input type="text" name="vlm_api_key" autocomplete="off" />
</label>
<label>VLM 模型
<input type="text" name="vlm_model" autocomplete="off" />
</label>
<label>LLM URL
<input type="text" name="llm_url" autocomplete="off" />
</label>
<label>LLM API Key
<input type="text" name="llm_api_key" autocomplete="off" />
</label>
<label>LLM 模型
<input type="text" name="llm_model" autocomplete="off" />
</label>
<label>手写敏感度 (建议 1.0)
<input type="text" name="sensitivity_factor" autocomplete="off" />
</label>
<label>最大并发数
<input type="number" name="max_workers" min="1" />
</label>
<label>最大重试次数
<input type="number" name="max_retries" min="1" />
</label>
<label>重试延迟 (秒)
<input type="number" name="retry_delay" min="1" />
</label>
<label>输出目录
<input type="text" name="output_directory" autocomplete="off" />
</label>
</div>
<div class="grid-2">
<label class="checkbox-row"><input type="checkbox" name="save_markdown" />保存 Markdown</label>
<label class="checkbox-row"><input type="checkbox" name="render_markdown" />渲染 HTML 报告</label>
<label class="checkbox-row"><input type="checkbox" name="auto_update_check" />启动时检查更新</label>
</div>
<label>LLM Prompt 模板
<textarea name="llm_prompt" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit" id="save-settings">保存设置</button>
<button type="button" id="reset-template" class="ghost-btn">恢复默认模板</button>
</div>
</form>
</div>
</div>
<div class="view" data-view-section="about">
<div class="section">
<h2>关于与更新</h2>
<div class="about-card">
<div><strong>当前版本:</strong><span id="about-current">{{ current_version }}</span></div>
<div id="about-latest">正在获取最新版本信息...</div>
<div id="about-checked" class="muted"></div>
<div class="actions">
<button class="ghost-btn" id="check-updates">检查更新</button>
</div>
</div>
<div class="about-card">
<strong>使用提示</strong>
<ul>
<li>默认使用 <code>output_reports/时间戳</code> 保存批改文件,可在设置中修改。</li>
<li>可单独保存 Markdown 或 HTML也可保留二者。</li>
<li>Prompt 模板支持完全自定义,请保留参数占位符以确保正常传值。</li>
</ul>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const navButtons = document.querySelectorAll('.nav-btn');
const views = document.querySelectorAll('[data-view-section]');
const settingsForm = document.getElementById('settings-form');
const processForm = document.getElementById('process-form');
const fileInput = processForm.querySelector('input[name="files"]');
const startButton = document.getElementById('start-process');
const resultsPanel = document.getElementById('results');
const statusBox = document.getElementById('process-status');
const toast = document.getElementById('toast');
const usagePill = document.getElementById('usage-pill');
const versionInfo = document.getElementById('version-info');
const resetTemplateBtn = document.getElementById('reset-template');
const checkUpdatesBtn = document.getElementById('check-updates');
const aboutCurrent = document.getElementById('about-current');
const aboutLatest = document.getElementById('about-latest');
const aboutChecked = document.getElementById('about-checked');
let pollTimer = null;
let currentRunId = null;
function switchView(view) {
views.forEach((section) => {
section.classList.toggle('active', section.dataset.viewSection === view);
});
navButtons.forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === view);
});
}
navButtons.forEach((btn) => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
function showToast(message, type = 'info') {
toast.textContent = message;
toast.classList.add('show');
if (type === 'error') {
toast.style.background = 'rgba(220, 53, 69, 0.9)';
} else if (type === 'success') {
toast.style.background = 'rgba(46, 204, 113, 0.9)';
} else {
toast.style.background = 'rgba(16, 20, 24, 0.85)';
}
setTimeout(() => toast.classList.remove('show'), 2800);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function updateAboutInfo(data) {
aboutCurrent.textContent = data.CurrentVersion;
if (data.LatestVersion && data.LatestVersion !== data.CurrentVersion) {
aboutLatest.textContent = `发现新版本 ${data.LatestVersion}`;
} else if (data.LatestVersion) {
aboutLatest.textContent = '当前已是最新版本。';
} else {
aboutLatest.textContent = '尚未检测到新版本。';
}
aboutChecked.textContent = data.CheckedAt ? `最近检查:${data.CheckedAt}` : '';
}
function populateConfig(data) {
settingsForm.vlm_url.value = data.VlmUrl || '';
settingsForm.vlm_api_key.value = '';
settingsForm.vlm_model.value = data.VlmModel || '';
settingsForm.llm_url.value = data.LlmUrl || '';
settingsForm.llm_api_key.value = '';
settingsForm.llm_model.value = data.LlmModel || '';
settingsForm.sensitivity_factor.value = data.SensitivityFactor || '';
settingsForm.max_workers.value = data.MaxWorkers || 4;
settingsForm.max_retries.value = data.MaxRetries || 3;
settingsForm.retry_delay.value = data.RetryDelay || 5;
settingsForm.output_directory.value = data.OutputDirectory || '{{ default_output_dir }}';
settingsForm.save_markdown.checked = !!data.SaveMarkdown;
settingsForm.render_markdown.checked = !!data.RenderMarkdown;
settingsForm.auto_update_check.checked = !!data.AutoUpdateCheck;
settingsForm.llm_prompt.value = data.LlmPromptTemplate || '';
usagePill.textContent = `VLM ${data.Usage.vlm_input}/${data.Usage.vlm_output} · LLM ${data.Usage.llm_input}/${data.Usage.llm_output}`;
const hasVlmKey = !!data.HasVlmApiKey;
const hasLlmKey = !!data.HasLlmApiKey;
if (hasVlmKey) {
settingsForm.vlm_api_key.placeholder = '已保存 · 输入新密钥以更新';
settingsForm.vlm_api_key.dataset.saved = 'true';
settingsForm.vlm_api_key.title = '已保存,留空保持不变';
} else {
settingsForm.vlm_api_key.placeholder = '';
delete settingsForm.vlm_api_key.dataset.saved;
settingsForm.vlm_api_key.removeAttribute('title');
}
if (hasLlmKey) {
settingsForm.llm_api_key.placeholder = '已保存 · 输入新密钥以更新';
settingsForm.llm_api_key.dataset.saved = 'true';
settingsForm.llm_api_key.title = '已保存,留空保持不变';
} else {
settingsForm.llm_api_key.placeholder = '';
delete settingsForm.llm_api_key.dataset.saved;
settingsForm.llm_api_key.removeAttribute('title');
}
if (data.LatestVersion && data.LatestVersion !== data.CurrentVersion) {
versionInfo.textContent = `版本 ${data.CurrentVersion} · 发现新版本 ${data.LatestVersion}`;
} else {
versionInfo.textContent = `版本 ${data.CurrentVersion}`;
}
updateAboutInfo(data);
}
async function loadConfig() {
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
populateConfig(data);
} catch (err) {
console.error(err);
showToast('加载配置失败', 'error');
}
}
settingsForm.addEventListener('submit', async (event) => {
event.preventDefault();
const payload = {
VlmUrl: settingsForm.vlm_url.value.trim(),
VlmApiKey: settingsForm.vlm_api_key.value.trim(),
VlmModel: settingsForm.vlm_model.value.trim(),
LlmUrl: settingsForm.llm_url.value.trim(),
LlmApiKey: settingsForm.llm_api_key.value.trim(),
LlmModel: settingsForm.llm_model.value.trim(),
SensitivityFactor: settingsForm.sensitivity_factor.value.trim(),
MaxWorkers: settingsForm.max_workers.value,
MaxRetries: settingsForm.max_retries.value,
RetryDelay: settingsForm.retry_delay.value,
OutputDirectory: settingsForm.output_directory.value.trim(),
SaveMarkdown: settingsForm.save_markdown.checked,
RenderMarkdown: settingsForm.render_markdown.checked,
AutoUpdateCheck: settingsForm.auto_update_check.checked,
LlmPromptTemplate: settingsForm.llm_prompt.value,
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || '保存失败');
}
showToast('设置已保存', 'success');
loadConfig();
} catch (err) {
showToast(err.message, 'error');
}
});
resetTemplateBtn.addEventListener('click', () => {
fetch('/api/config')
.then((res) => res.json())
.then((data) => {
settingsForm.llm_prompt.value = data.LlmPromptTemplate;
showToast('已恢复默认模板');
})
.catch(() => showToast('恢复失败', 'error'));
});
processForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (pollTimer) {
showToast('上一个任务仍在进行,请稍候', 'error');
return;
}
const formData = new FormData(processForm);
if (!(formData.get('topic') || '').trim()) {
showToast('请填写作文题目', 'error');
return;
}
if (!fileInput.files.length) {
showToast('请至少选择一张图片', 'error');
return;
}
startButton.disabled = true;
statusBox.textContent = '正在排队...';
resultsPanel.innerHTML = '<div class="banner">任务已提交,正在排队...</div>';
try {
const res = await fetch('/api/process', {
method: 'POST',
body: formData,
});
if (!res.ok) {
startButton.disabled = false;
const data = await res.json().catch(() => ({}));
throw new Error(data.error || '处理失败');
}
const data = await res.json();
if (!data.run_id) {
startButton.disabled = false;
throw new Error('任务启动失败');
}
statusBox.textContent = `正在批改:已完成 0 / ${data.total || 0}`;
renderResults({
status: 'queued',
total: data.total || 0,
completed: 0,
results: [],
aggregate: { vlm_in: 0, vlm_out: 0, llm_in: 0, llm_out: 0 },
run_path: data.run_path,
});
startPolling(data.run_id);
} catch (err) {
showToast(err.message, 'error');
statusBox.textContent = '';
startButton.disabled = false;
}
});
function startPolling(runId) {
currentRunId = runId;
stopPolling();
pollRunStatus();
pollTimer = setInterval(pollRunStatus, 1500);
}
async function pollRunStatus() {
if (!currentRunId) {
return;
}
try {
const res = await fetch(`/api/run-status/${currentRunId}`);
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json();
const total = data.total || 0;
const completed = data.completed || 0;
if (data.status === 'queued' || data.status === 'running') {
statusBox.textContent = `正在批改:已完成 ${completed} / ${total}`;
renderResults(data);
} else {
stopPolling();
currentRunId = null;
startButton.disabled = false;
if (data.status === 'ok') {
statusBox.textContent = '任务完成';
showToast('任务完成', 'success');
} else if (data.status === 'partial') {
statusBox.textContent = '任务部分失败';
showToast('部分文件处理失败', 'error');
} else if (data.status === 'failed') {
statusBox.textContent = '任务失败';
showToast(data.error || '任务失败', 'error');
} else if (data.status === 'empty') {
statusBox.textContent = '无可处理的文件';
showToast('没有可处理的文件', 'info');
} else {
statusBox.textContent = '任务完成';
}
renderResults(data);
loadConfig();
}
} catch (err) {
console.error(err);
showToast('获取进度失败', 'error');
stopPolling();
startButton.disabled = false;
currentRunId = null;
}
}
function renderResults(data) {
const status = data.status || 'running';
const total = data.total || 0;
const completed = data.completed || 0;
const results = Array.isArray(data.results) ? data.results : [];
const aggregate = data.aggregate || {};
const statusMap = {
queued: '排队中',
running: '处理中',
ok: '完成',
partial: '部分完成',
failed: '失败',
empty: '无结果',
};
const locationNote = data.run_path ? ` · 输出目录 ${data.run_path}` : '';
const summary = `${statusMap[status] || status} · 已完成 ${completed}/${total}${locationNote}`;
const aggLine = `合计 | VLM ${aggregate.vlm_in || 0}/${aggregate.vlm_out || 0} · LLM ${aggregate.llm_in || 0}/${aggregate.llm_out || 0}`;
let content = `<div class="banner">${summary}</div><div class="banner">${aggLine}</div>`;
if (!results.length) {
content += '<div class="banner">暂无结果,请稍候...</div>';
resultsPanel.innerHTML = content;
return;
}
const cards = results
.map((item) => {
const statusClass = item.error ? 'result-card error' : 'result-card success';
const links = [];
if (item.saved) {
links.push(`<a href="/outputs/${item.saved}" target="_blank">原图</a>`);
}
if (item.markdown) {
links.push(`<a href="/outputs/${item.markdown}" target="_blank">Markdown</a>`);
}
if (item.html) {
links.push(`<a href="/outputs/${item.html}" target="_blank">HTML</a>`);
}
const usage = `VLM ${item.vlm_usage.prompt_tokens}/${item.vlm_usage.completion_tokens} · LLM ${item.llm_usage.prompt_tokens}/${item.llm_usage.completion_tokens}`;
const logLines = (item.logs || []).map((log) => `<div>• ${log}</div>`).join('');
const errorBlock = item.error ? `<strong style="color:#d93025;">${item.error}</strong>` : '';
return `
<div class="${statusClass}">
<div class="result-header">
<div>
<strong>${item.original}</strong>
<div style="font-size:12px;color:rgba(16,20,24,0.55);">${usage}</div>
</div>
<div class="result-links">${links.join('')}</div>
</div>
<div class="logs">${errorBlock}${logLines}</div>
</div>
`;
})
.join('');
resultsPanel.innerHTML = content + cards;
}
async function refreshUpdateStatus(showToastOnNew = false) {
try {
const res = await fetch('/api/update-status');
if (!res.ok) return;
const data = await res.json();
if (data.current) {
versionInfo.textContent = `版本 ${data.current}`;
aboutCurrent.textContent = data.current;
}
if (data.latest && data.latest !== data.current) {
versionInfo.textContent = `版本 ${data.current} · 发现新版本 ${data.latest}`;
aboutLatest.textContent = `发现新版本 ${data.latest}`;
if (showToastOnNew) {
showToast(`发现新版本 ${data.latest}`, 'success');
}
} else if (data.latest) {
aboutLatest.textContent = '当前已是最新版本。';
} else {
aboutLatest.textContent = '尚未检测到新版本。';
}
if (data.checked) {
aboutChecked.textContent = `最近检查:${data.checked}`;
}
} catch (err) {
console.warn('更新检查失败', err);
}
}
checkUpdatesBtn.addEventListener('click', async () => {
try {
await fetch('/api/update-check', { method: 'POST' });
showToast('正在检查更新...');
setTimeout(() => refreshUpdateStatus(true), 2000);
} catch (err) {
showToast('检查失败', 'error');
}
});
switchView('grading');
loadConfig().then(() => refreshUpdateStatus());
</script>
</body>
</html>
'''
return render_template_string(
html,
current_version=CURRENT_VERSION,
default_output_dir=DEFAULT_OUTPUT_DIR_NAME,
)
start_update_check()
return app