import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading import queue import os from typing import List import concurrent.futures from config_manager import ConfigManager from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE class AboutDialog(tk.Toplevel): """“关于”对话框,展示应用信息,支持滚动查看。""" def __init__(self, parent, config_manager: ConfigManager): super().__init__(parent) self.transient(parent) self.title("关于 AI 作文批改助手") # 设置一个适合滚动的默认窗口尺寸 self.geometry("450x400") # 主框架,用于容纳文本和滚动条 main_frame = ttk.Frame(self) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) main_frame.grid_rowconfigure(0, weight=1) main_frame.grid_columnconfigure(0, weight=1) # 使用Text控件以支持长文本和滚动条 text_widget = tk.Text(main_frame, wrap="word", relief="flat", spacing1=5, spacing3=5) text_widget.grid(row=0, column=0, sticky="nsew") # 创建并关联垂直滚动条 scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=text_widget.yview) scrollbar.grid(row=0, column=1, sticky="ns") text_widget.config(yscrollcommand=scrollbar.set) vlm_in = config_manager.get('UsageVlmInput', 0) vlm_out = config_manager.get('UsageVlmOutput', 0) llm_in = config_manager.get('UsageLlmInput', 0) llm_out = config_manager.get('UsageLlmOutput', 0) about_text = f""" 欢迎使用 AI 作文批改助手!这是一款专为教育者和学生设计的智能工具,旨在利用前沿的人工智能技术,提供高效、精准、个性化的英文作文批改体验。 核心亮点: - **双核AI引擎:** 采用先进的两步式处理流程。首先由专用的视觉语言模型(VLM)对作文图片进行高精度手写识别(OCR)与专业的书写质量评估;随后,强大的大语言模型(LLM)会结合识别出的文本、作文题目以及书写评分,进行深度、全面的分析与批改。 - **极致的灵活性与可配置性:** * **服务分离:** 您可以为VLM和LLM设置完全独立的API服务地址、密钥和模型名称,轻松适配不同的AI提供商(需要兼容OpenAI格式)或自建服务。 * **逻辑定制:** 书写评分的“敏感度因子”可在设置中调整,以适应不同年级或要求的评分标准。 * **模板开放:** 核心的LLM批改指令模板(Prompt)完全开放给用户。您可以在设置中自由修改,调整评分维度、总分、反馈风格等,实现高度个性化的批改要求。 - **闪电般的并发处理:** 内置高效的多线程并发引擎,无论您选择一张还是上百张图片,程序都能同时处理,大幅缩短批量批改的等待时间。最大并发任务数亦可在设置中自由调整。 - **企业级的安全保障:** 我们深知API密钥的敏感性。所有密钥信息在保存到本地配置文件时,均经过强大的加密算法处理,有效防止明文泄露,保障您的账户安全。 - **人性化的评分策略:** 卷面书写分采用更符合教学直觉的“向上取整至0.5分”规则,确保评分结果既精确又公平。 使用说明: 1. **初次配置:** 点击“设置”,分别填入您的VLM和LLM服务提供商的URL、API密钥和模型名称。 2. **输入题目:** 在主界面上方的文本框中,输入本次批改的“作文题目”。 3. **选择文件:** 点击“选择图片”,一次性选择所有需要批改的学生作文图片。 4. **开始批改:** 点击“开始批改”,程序将自动在后台进行并发处理,您可以在日志区看到实时进度。 5. **获取报告:** 任务完成后,每一张图片对应的Markdown格式详细批改报告,都会自动生成在原图片所在的目录下。 作者: Eric_Terminal https://github.com/Eric-Terminal 版本: 2.5 --- 历史Token使用量 (仅供参考): - VLM 输入: {vlm_in:,} - VLM 输出: {vlm_out:,} - LLM 输入: {llm_in:,} - LLM 输出: {llm_out:,} """ text_widget.insert("1.0", about_text) # 将文本设置为只读,防止用户修改 text_widget.config(state="disabled") # 放置“关闭”按钮的框架 btn_frame = ttk.Frame(main_frame) btn_frame.grid(row=1, column=0, columnspan=2, sticky="e", pady=(10, 0)) close_button = ttk.Button(btn_frame, text="关闭", command=self.destroy) close_button.pack() self.protocol("WM_DELETE_WINDOW", self.destroy) self.grab_set() self.wait_window(self) class SettingsDialog(tk.Toplevel): """“设置”对话框,允许用户配置VLM、LLM服务及其他应用参数。""" def __init__(self, parent, current_config: dict): super().__init__(parent) self.transient(parent) self.title("设置") self.result = None # 为VLM和LLM服务分别创建Tkinter字符串变量 self.vlm_url = tk.StringVar(value=current_config.get("VlmUrl", "https://api.siliconflow.cn/v1/")) self.vlm_api_key = tk.StringVar(value=current_config.get("VlmApiKey", "")) self.vlm_model = tk.StringVar(value=current_config.get("VlmModel", "Pro/THUDM/GLM-4.1V-9B-Thinking")) self.llm_url = tk.StringVar(value=current_config.get("LlmUrl", "https://api.siliconflow.cn/v1/")) self.llm_api_key = tk.StringVar(value=current_config.get("LlmApiKey", "")) self.llm_model = tk.StringVar(value=current_config.get("LlmModel", "moonshotai/Kimi-K2-Instruct")) self.sensitivity_factor = tk.StringVar(value=current_config.get("SensitivityFactor", "1.5")) self.max_workers = tk.StringVar(value=current_config.get("MaxWorkers", "4")) # 智能加载Prompt模板:优先使用用户自定义模板,否则使用默认模板 user_template = current_config.get("LlmPromptTemplate") self.llm_prompt_template_str = user_template if user_template else DEFAULT_LLM_PROMPT_TEMPLATE frame = ttk.Frame(self, padding="10") frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) frame.grid_columnconfigure(0, weight=1) # VLM服务设置区域 vlm_frame = ttk.LabelFrame(frame, text="VLM (视觉模型) 设置", padding="10") vlm_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) vlm_frame.grid_columnconfigure(1, weight=1) ttk.Label(vlm_frame, text="VLM URL:").grid(column=0, row=0, sticky=tk.W, pady=2) ttk.Entry(vlm_frame, textvariable=self.vlm_url, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) ttk.Label(vlm_frame, text="VLM API Key:").grid(column=0, row=1, sticky=tk.W, pady=2) ttk.Entry(vlm_frame, textvariable=self.vlm_api_key, width=40).grid(column=1, row=1, sticky=(tk.W, tk.E)) ttk.Label(vlm_frame, text="VLM 模型:").grid(column=0, row=2, sticky=tk.W, pady=2) ttk.Entry(vlm_frame, textvariable=self.vlm_model, width=40).grid(column=1, row=2, sticky=(tk.W, tk.E)) # LLM服务设置区域 llm_frame = ttk.LabelFrame(frame, text="LLM (语言模型) 设置", padding="10") llm_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5) llm_frame.grid_columnconfigure(1, weight=1) ttk.Label(llm_frame, text="LLM URL:").grid(column=0, row=0, sticky=tk.W, pady=2) ttk.Entry(llm_frame, textvariable=self.llm_url, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) ttk.Label(llm_frame, text="LLM API Key:").grid(column=0, row=1, sticky=tk.W, pady=2) ttk.Entry(llm_frame, textvariable=self.llm_api_key, width=40).grid(column=1, row=1, sticky=(tk.W, tk.E)) ttk.Label(llm_frame, text="LLM 模型:").grid(column=0, row=2, sticky=tk.W, pady=2) ttk.Entry(llm_frame, textvariable=self.llm_model, width=40).grid(column=1, row=2, sticky=(tk.W, tk.E)) # 其他应用参数设置区域 other_frame = ttk.LabelFrame(frame, text="其他设置", padding="10") other_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=5) other_frame.grid_columnconfigure(1, weight=1) ttk.Label(other_frame, text="手写打分敏感度:").grid(column=0, row=0, sticky=tk.W, pady=2) ttk.Entry(other_frame, textvariable=self.sensitivity_factor, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) ttk.Label(other_frame, text="最大并发数:").grid(column=0, row=1, sticky=tk.W, pady=2) ttk.Entry(other_frame, textvariable=self.max_workers, width=40).grid(column=1, row=1, sticky=(tk.W, tk.E)) # LLM Prompt模板编辑区域 prompt_frame = ttk.LabelFrame(frame, text="LLM Prompt 模板 (可在此修改,请勿修改{}占位符内容导致程序参数无法正常传递,通常情况下修改总分即可)", padding="10") prompt_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=5) prompt_frame.grid_columnconfigure(0, weight=1) prompt_frame.grid_rowconfigure(0, weight=1) self.llm_prompt_text = tk.Text(prompt_frame, height=10, wrap="word") self.llm_prompt_text.grid(row=0, column=0, sticky="nsew") prompt_scrollbar = ttk.Scrollbar(prompt_frame, orient="vertical", command=self.llm_prompt_text.yview) prompt_scrollbar.grid(row=0, column=1, sticky="ns") self.llm_prompt_text.config(yscrollcommand=prompt_scrollbar.set) self.llm_prompt_text.insert("1.0", self.llm_prompt_template_str) btn_frame = ttk.Frame(frame) btn_frame.grid(row=4, column=0, sticky=tk.E, pady=10) ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="关闭", command=self.on_close).pack(side=tk.LEFT) self.protocol("WM_DELETE_WINDOW", self.on_close) self.grab_set() self.wait_window(self) def on_ok(self): self.result = { "VlmUrl": self.vlm_url.get(), "VlmApiKey": self.vlm_api_key.get(), "VlmModel": self.vlm_model.get(), "LlmUrl": self.llm_url.get(), "LlmApiKey": self.llm_api_key.get(), "LlmModel": self.llm_model.get(), "SensitivityFactor": self.sensitivity_factor.get(), "MaxWorkers": self.max_workers.get(), "LlmPromptTemplate": self.llm_prompt_text.get("1.0", "end-1c") } # 如果用户修改后的模板与默认模板内容一致,则不写入配置文件,以使用默认值 if self.result["LlmPromptTemplate"].strip() == DEFAULT_LLM_PROMPT_TEMPLATE.strip(): self.result["LlmPromptTemplate"] = None # 使用 None 作为信号,表示应移除此配置项 self.destroy() def on_close(self): self.result = None self.destroy() class MainApp: """应用主窗口类,负责构建UI界面、处理用户交互和协调后台服务。""" def __init__(self, root: tk.Tk, config_manager: ConfigManager, api_service: ApiService): self.root = root self.config_manager = config_manager self.api_service = api_service self.file_paths: List[str] = [] self.is_file_selected = False self.ui_queue = queue.Queue() self.topic_input = None self.processed_count = 0 self.lock = threading.Lock() self._setup_ui() self._initialize_config() self.root.after(100, self._process_ui_queue) def _setup_ui(self): """初始化和布局主窗口的所有UI组件。""" self.root.title("AI 作文批改助手") self.root.geometry("550x450") main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(0, weight=1) # 顶部进度条 top_frame = ttk.Frame(main_frame) top_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5) top_frame.grid_columnconfigure(0, weight=1) self.progress_bar = ttk.Progressbar(top_frame, orient="horizontal", mode="determinate") self.progress_bar.grid(row=0, column=0, sticky=(tk.W, tk.E)) # 左侧控制按钮 left_frame = ttk.Frame(main_frame) left_frame.grid(row=1, column=0, sticky=(tk.N, tk.W), padx=(0, 10)) ttk.Button(left_frame, text="选择图片", command=self._open_file_dialog).pack(fill=tk.X, pady=5) ttk.Button(left_frame, text="开始批改", command=self._start_processing).pack(fill=tk.X, pady=5) ttk.Button(left_frame, text="设置", command=self._open_settings_dialog).pack(fill=tk.X, pady=5) ttk.Button(left_frame, text="关于", command=self._open_about_dialog).pack(fill=tk.X, pady=5) # 右侧输入和日志区域 right_frame = ttk.Frame(main_frame) right_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) main_frame.grid_columnconfigure(1, weight=1) main_frame.grid_rowconfigure(1, weight=1) # 配置右侧框架的网格权重,使组件能自适应缩放 right_frame.grid_rowconfigure(0, weight=7) # 作文题目框占7份 right_frame.grid_rowconfigure(1, weight=3) # 日志框占3份 right_frame.grid_columnconfigure(0, weight=1) # 作文题目输入框 self.topic_input = tk.Text(right_frame, wrap="word") self.topic_input.grid(row=0, column=0, sticky="nsew", pady=(0, 5)) self.topic_input.insert("1.0", "(在此输入作文题目)") self.topic_input.config(fg="grey") # 实现输入框的占位符(placeholder)效果 def on_focus_in(event): if self.topic_input.get("1.0", "end-1c").strip() == "(在此输入作文题目)": self.topic_input.delete("1.0", tk.END) self.topic_input.config(fg="black") def on_focus_out(event): if not self.topic_input.get("1.0", "end-1c").strip(): self.topic_input.insert("1.0", "(在此输入作文题目)") self.topic_input.config(fg="grey") self.topic_input.bind("", on_focus_in) self.topic_input.bind("", on_focus_out) # 日志输出框(带滚动条) listbox_frame = ttk.Frame(right_frame) listbox_frame.grid(row=1, column=0, sticky="nsew") listbox_frame.grid_rowconfigure(0, weight=1) listbox_frame.grid_columnconfigure(0, weight=1) self.listbox = tk.Listbox(listbox_frame) self.listbox.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(listbox_frame, orient="vertical", command=self.listbox.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.listbox.config(yscrollcommand=scrollbar.set) def _log(self, message: str): """将消息添加到日志列表框并滚动到底部。""" self.listbox.insert(tk.END, message) self.listbox.see(tk.END) def _initialize_config(self): """应用启动时检查配置完整性,如果配置不完整则强制用户设置。""" is_ok, _ = self.config_manager.check_settings() if not is_ok: self._show_config_dialog_until_valid() def _show_config_dialog_until_valid(self): """循环显示设置对话框,直到所有必需配置项都已填写。""" while True: self._open_settings_dialog() is_ok, missing_item = self.config_manager.check_settings() if is_ok: return if messagebox.askretrycancel("配置未完成", f"请配置: {missing_item}") == "cancel": self.root.quit() return def _open_settings_dialog(self): """打开设置对话框,并根据返回结果更新和保存配置。""" dialog = SettingsDialog(self.root, self.config_manager.config) if dialog.result: # 清理旧的、统一的AI配置和OCR配置,以兼容新版分离的配置 self.config_manager.config.pop("AiUrl", None) self.config_manager.config.pop("AiApiKey", None) self.config_manager.config.pop("OcrApiKey", None) self.config_manager.config.pop("OcrSecretKey", None) for key, value in dialog.result.items(): if key == "LlmPromptTemplate": if value is None: # 如果值为None,表示用户希望恢复默认模板,因此从配置中移除该键 self.config_manager.config.pop(key, None) else: self.config_manager.set(key, value) else: self.config_manager.set(key, value) self.config_manager.save() def _open_about_dialog(self): """创建并显示“关于”对话框。""" AboutDialog(self.root, self.config_manager) def _open_file_dialog(self): """打开文件选择对话框,让用户选择一个或多个图片文件。""" paths = filedialog.askopenfilenames(title="选择作文图片", filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp")]) if paths: self.file_paths = paths self.is_file_selected = True self._log(f"已选择 {len(paths)} 个文件") else: self._log("取消选择") def _start_processing(self): """启动作文批改流程。""" if not self.is_file_selected: messagebox.showerror("操作错误", "请先选择文件") return topic = self.topic_input.get("1.0", tk.END).strip() if not topic or topic == "(在此输入作文题目)": messagebox.showerror("操作错误", "请输入作文题目") return # 重置进度条和计数器 self.progress_bar['value'] = 0 self.progress_bar['maximum'] = len(self.file_paths) self.processed_count = 0 # 在后台线程中启动并发处理 thread = threading.Thread(target=self._concurrent_worker_manager, args=(self.file_paths, topic), daemon=True) thread.start() def _process_ui_queue(self): """定期检查UI更新队列,并执行相应的UI操作(如记日志、更新进度条)。""" try: while True: task, data = self.ui_queue.get_nowait() if task == "log": self._log(data) elif task == "progress": with self.lock: self.processed_count += 1 self.progress_bar['value'] = self.processed_count elif task == "finish": messagebox.showinfo("完成", "所有文件处理完成") self.progress_bar['value'] = 0 self.is_file_selected = False except queue.Empty: pass finally: # 持续轮询队列 self.root.after(100, self._process_ui_queue) def _concurrent_worker_manager(self, file_paths: List[str], topic: str): """使用线程池并发处理所有选定的文件。""" try: max_workers = int(self.config_manager.get("MaxWorkers", "4")) except (ValueError, TypeError): max_workers = 4 with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: for file_path in file_paths: executor.submit(self._process_single_file, file_path, topic) # 所有任务完成后,向UI队列发送完成信号 self.ui_queue.put(("finish", None)) def _process_single_file(self, file_path: str, topic: str): """处理单个图片文件的完整流程:调用API、保存报告、更新UI队列。""" base_name = os.path.basename(file_path) self.ui_queue.put(("log", f"开始处理: {base_name}")) try: final_report, vlm_usage, llm_usage = self.api_service.process_essay_image(file_path, topic) report_filename = os.path.splitext(file_path)[0] + "_report.md" with open(report_filename, 'w', encoding='utf-8') as f: f.write(final_report) vlm_in = vlm_usage.get("prompt_tokens", 0) vlm_out = vlm_usage.get("completion_tokens", 0) llm_in = llm_usage.get("prompt_tokens", 0) llm_out = llm_usage.get("completion_tokens", 0) usage_log = f"Token用量: VLM(in:{vlm_in}, out:{vlm_out}), LLM(in:{llm_in}, out:{llm_out})" self.ui_queue.put(("log", f"完成批改: {base_name} -> {os.path.basename(report_filename)}")) self.ui_queue.put(("log", usage_log)) # 加锁以保证线程安全地更新和保存配置 with self.lock: self.config_manager.update_token_usage(vlm_in, vlm_out, llm_in, llm_out) self.config_manager.save() except Exception as e: self.ui_queue.put(("log", f"文件: {base_name} 失败: {e}")) # 无论成功或失败,都更新进度 self.ui_queue.put(("progress", 1))