diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..f0129bd --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,58 @@ +# 工作流的名称 +name: Build Windows Executable + +# 触发条件: +# 1. 当代码被推送到 main 分支时 +# 2. 当你创建一个新的 "Tag" (用于发布新版本) +# 3. 允许你在GitHub页面手动触发 +on: + push: + branches: [ "main" ] + release: + types: [ created ] + workflow_dispatch: + +jobs: + build: + # 指定运行环境为最新的Windows服务器 + runs-on: windows-latest + + steps: + # 第1步:检出你的代码 + - name: Checkout repository + uses: actions/checkout@v4 + + # 第2步:设置Python环境 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' # 你可以根据需要更改Python版本 + + # 第3步:安装依赖包 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + # 第4步:使用PyInstaller打包 + # --noconsole: 这是一个GUI应用,不显示控制台窗口 + # --onefile: 打包成单个.exe文件 + # --add-data: 将 config.json 文件包含到exe中 + # --name: 指定生成的可执行文件名 + - name: Build with PyInstaller + run: pyinstaller --noconsole --onefile --name "AI-Essay-Corrector" --add-data "config.json;." main.py + + # 第5步:将打包好的.exe上传,以便在工作流页面下载 (适合测试) + - name: Upload artifact for testing + uses: actions/upload-artifact@v4 + with: + name: AI-Essay-Corrector-Windows-exe + path: dist/AI-Essay-Corrector.exe + + # 第6步 (专业级开源发布): 当你创建Tag时,自动创建Release并附上.exe + - name: Create Release and Upload Asset + if: startsWith(github.ref, 'refs/tags/') # 仅在创建Tag时运行此步骤 + uses: softprops/action-gh-release@v2 + with: + files: dist/AI-Essay-Corrector.exe \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3703fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python virtual environment +venv/ +.venv/ +env/ +ENV/ + +# Python cache files +__pycache__/ +*.pyc +*.pyo +*.pyd + +# PyInstaller build artifacts +dist/ +build/ +*.spec + +# IDE-specific files +.vscode/ +.idea/ + +# OS-specific files +.DS_Store +Thumbs.db + +# Local configuration file +# This file is generated by the application and contains user-specific secrets. +# It should not be committed to version control. +config.json \ No newline at end of file diff --git a/api_services.py b/api_services.py new file mode 100644 index 0000000..3f7bedb --- /dev/null +++ b/api_services.py @@ -0,0 +1,163 @@ +import base64 +from typing import Dict, Any +from config_manager import ConfigManager +import os +import mimetypes +import re +from openai import OpenAI + +# 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。 +DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC +{topic} + +# INSTRUCTIONS FOR AI (Process in English) +## 1. ROLE & GOAL +You are a highly experienced senior high school English teacher. Your task is to provide a detailed, constructive, and encouraging evaluation of a student's essay. +## 2. INPUT DATA +You will receive a quantitative `` and the full `` of the essay. The essay is based on the topic provided above. +## 3. GRADING LOGIC (Total Score: 15 points) +- **Content & Language (12 points):** Evaluate this based on grammar, vocabulary, sentence structure, etc., in relation to the essay topic. +- **Handwriting & Presentation (3 points):** Calculate the score by first getting a raw score (`Raw Score = wscore * 3`), and then rounding the `Raw Score` **up** to the nearest half-point (0.5). + - *Rounding Logic Example:* A raw score of 2.49 becomes 2.5. A raw score of 2.51 becomes 3.0. A raw score of 2.50 remains 2.5. A score of 0 remains 0. +## 4. FINAL TASK +Analyze the text, calculate scores, and present your feedback in **Simplified Chinese** using the precise Markdown format specified below. +#--- End of English Instructions --- +# OUTPUT SPECIFICATION (MUST BE IN SIMPLIFIED CHINESE) +# 请严格使用以下Markdown格式,并用简体中文填充所有内容,优点可以两个到三个,问题建议要把全部问题找出来并且解析,都要遵循类似格式。 + + +###【作文内容】 +* **作文文本:** [在此处粘贴完整的作文文本。] +### 【综合评价】 +(在此处用一两句鼓励性的话,对本次作文进行总体概述。) +### 【亮点与优点】 +* **(优点1):** [具体描述作文内容或语言上的一个亮点。] +* **(优点2):** [具体描述另一个优点。] +* **(优点3):(以此类推,不限制数量,但建议控制在3个以内。) +### 【问题与修改建议】 +* **[问题1 - 语法/拼写错误]:** + * **原文句子:** "[引用出现错误的原文句子]" + * **问题分析:** [简要说明错误类型。] + * **修改建议:** "[写出修改后的正确句子]" +* **[问题2 - 表达/逻辑]:** + * **原文句子:** "[引用表达欠佳的原文句子]" + * **问题分析:** [说明问题所在。] + * **修改建议:** "[提供一个更好的表达方式。] +* **[问题3 - (以此类推,不限制数量,但建议控制在3个以内。)]:** + * **原文句子:** "[说明问题所在。]" + * **问题分析:** [说明问题所在。] +### 【分数评估】 +* **内容与语言分 (Content & Language):** [分数] / 12 +* **卷面与书写分 (Handwriting & Presentation):** [分数] / 3 +* --- +* **最终得分 (Final Score):** **[总分] / 15** + +# INPUT DATA FOR THIS TASK + +{wscore} + +{essay_text} + +""" + +class ApiService: + """封装了与外部API(VLM和LLM)交互的所有逻辑。""" + def __init__(self, config_manager: ConfigManager): + self.config = config_manager + + def _encode_image_to_base64_url(self, image_path: str) -> str: + """将本地图片文件编码为Base64数据URL。""" + if not os.path.exists(image_path): + raise FileNotFoundError(f"Image file not found at: {image_path}") + mime_type, _ = mimetypes.guess_type(image_path) + if not mime_type or not mime_type.startswith('image'): + raise ValueError(f"File is not a recognizable image type: {mime_type}") + with open(image_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + return f"data:{mime_type};base64,{encoded_string}" + + def process_essay_image(self, file_path: str, topic: str) -> str: + """ + 执行完整的两步式作文批改流程: + 1. VLM调用:分析作文图片,提取手写文本和书写质量分数。 + 2. LLM调用:基于VLM的输出和作文题目,生成详细的批改报告。 + """ + # --- 步骤 1: 调用VLM进行图像分析 --- + vlm_client = OpenAI( + api_key=self.config.get("VlmApiKey"), + base_url=self.config.get("VlmUrl") + ) + base64_image_url = self._encode_image_to_base64_url(file_path) + + vlm_prompt = """# ROLE +You are a high-precision OCR (Optical Character Recognition) and handwriting analysis engine. Your only job is to analyze the provided image and output structured data. Do not add any conversational text or explanations. +# TASK +Analyze the handwriting quality and extract all text from the image. +## 1. Handwriting Quality Analysis: +- Critically evaluate the handwriting on a continuous scale from 0.0 to 1.0. +- The scoring must be stringent. A score of 1.0 is reserved for flawless, machine-printed-like perfection, which is virtually unattainable. +- **Score Tiers:** + - **0.90-0.99:** Near-perfect, professional calligrapher level. Extremely rare. + - **0.80-0.89:** Excellent, clear, consistent, and aesthetically pleasing. The best a top student can achieve. + - **0.70-0.79:** Good and very legible, but with minor inconsistencies in size or spacing. + - **0.60-0.69:** Clear and legible, but with noticeable inconsistencies. + - **Below 0.60:** Legibility is impacted. +- Output this score enclosed in a single XML tag. +## 2. Full Text Extraction: +- Perform a high-accuracy OCR on the entire image. +- Preserve the original line breaks and paragraph structure as best as possible. +- Output the full extracted text enclosed in a single XML tag. +# OUTPUT FORMAT +Strictly adhere to the following format. Do not output anything else. +[Your calculated score, e.g., 0.85] + +[The full extracted text from the image goes here.] +""" + vlm_messages = [{"role": "user", "content": [{"type": "text", "text": vlm_prompt}, {"type": "image_url", "image_url": {"url": base64_image_url}}]}] + + vlm_model = self.config.get("VlmModel", "gemini-2.5-pro") + vlm_response = vlm_client.chat.completions.create(model=vlm_model, messages=vlm_messages, max_tokens=4096, temperature=1) + vlm_output = vlm_response.choices[0].message.content or "" + + # 解析VLM返回的XML格式输出,提取分数和文本 + wscore_match = re.search(r'(.*?)', vlm_output, re.DOTALL) + text_match = re.search(r'(.*?)', vlm_output, re.DOTALL) + original_wscore = float(wscore_match.group(1).strip()) if wscore_match else 0.0 + essay_text = text_match.group(1).strip() if text_match else "错误:无法从图片中提取文本。" + + try: + sensitivity_factor = float(self.config.get("SensitivityFactor", "1.0")) + except (ValueError, TypeError): + # 如果配置的敏感度因子无效,则使用默认值1.0 + sensitivity_factor = 1.0 + + wscore = original_wscore ** sensitivity_factor + + if not text_match: + raise ValueError(f"VLM未能按预期格式返回,无法解析文本。模型返回:\n{vlm_output}") + + # --- 步骤 2: 调用LLM生成批改报告 --- + llm_client = OpenAI( + api_key=self.config.get("LlmApiKey"), + base_url=self.config.get("LlmUrl") + ) + + # 从配置加载Prompt模板,若用户未定义则使用默认模板 + prompt_template = self.config.get("LlmPromptTemplate") + if not prompt_template: + prompt_template = DEFAULT_LLM_PROMPT_TEMPLATE + + # 使用作文题目、书写分数和识别出的文本填充Prompt模板 + final_llm_prompt = prompt_template.format( + topic=topic, + wscore=wscore, + essay_text=essay_text + ) + + llm_messages = [{"role": "user", "content": final_llm_prompt}] + + llm_model = self.config.get("LlmModel", "gemini-2.5-pro") + llm_response = llm_client.chat.completions.create(model=llm_model, messages=llm_messages, temperature=1, max_tokens=16384) + final_report = llm_response.choices[0].message.content or "错误:AI未能生成报告。" + + return final_report \ No newline at end of file diff --git a/app_ui.py b/app_ui.py new file mode 100644 index 0000000..4c410d4 --- /dev/null +++ b/app_ui.py @@ -0,0 +1,387 @@ +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): + 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) + + about_text = """ +欢迎使用 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.4 +""" + + 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", "")) + self.vlm_api_key = tk.StringVar(value=current_config.get("VlmApiKey", "")) + self.vlm_model = tk.StringVar(value=current_config.get("VlmModel", "gemini-2.5-pro")) + self.llm_url = tk.StringVar(value=current_config.get("LlmUrl", "")) + self.llm_api_key = tk.StringVar(value=current_config.get("LlmApiKey", "")) + self.llm_model = tk.StringVar(value=current_config.get("LlmModel", "gemini-2.5-pro")) + 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") # 从Text控件获取用户修改后的Prompt模板 + } + 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(): + self.config_manager.set(key, value) + self.config_manager.save() + + def _open_about_dialog(self): + """创建并显示“关于”对话框。""" + AboutDialog(self.root) + + 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 = 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) + + self.ui_queue.put(("log", f"完成批改: {base_name} -> {os.path.basename(report_filename)}")) + + except Exception as e: + self.ui_queue.put(("log", f"文件: {base_name} 失败: {e}")) + + # 无论成功或失败,都更新进度 + self.ui_queue.put(("progress", 1)) diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..be49b36 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,107 @@ +import json +import os +import base64 +import hashlib +from typing import Dict, Optional, Tuple +from cryptography.fernet import Fernet, InvalidToken + +class ConfigManager: + """ + 管理应用的配置(`config.json`),包括加载、保存以及对敏感信息的自动加解密。 + """ + # 定义需要进行加密处理的配置项 + SENSITIVE_KEYS = ["VlmApiKey", "LlmApiKey"] + # 用于生成加密密钥的密码和盐。注意:修改这些值将导致旧的配置文件无法解密。 + _ENCRYPTION_PASSWORD = b"a-strong-but-not-public-password-for-this-app" + _SALT = b'salt_for_llm_app_config' + + def __init__(self, file_path: str = "config.json"): + self.file_path = file_path + self.config: Dict[str, str] = {} + self._fernet: Optional[Fernet] = None + self._initialize_encryption() + self.load() + + def _initialize_encryption(self): + """使用预设的密码和盐生成加密密钥,并初始化Fernet加密/解密实例。""" + kdf = hashlib.pbkdf2_hmac('sha256', self._ENCRYPTION_PASSWORD, self._SALT, 100000) + key = base64.urlsafe_b64encode(kdf) + self._fernet = Fernet(key) + + def _encrypt(self, value: str) -> str: + """使用初始化的Fernet实例加密字符串。""" + if not value or not self._fernet: + return "" + return self._fernet.encrypt(value.encode('utf-8')).decode('utf-8') + + def _decrypt(self, encrypted_value: str) -> str: + """ + 使用初始化的Fernet实例解密字符串。 + 如果解密失败(例如,值是旧的明文或已损坏),则返回空字符串以避免程序崩溃。 + """ + if not encrypted_value or not self._fernet: + return "" + try: + return self._fernet.decrypt(encrypted_value.encode('utf-8')).decode('utf-8') + except InvalidToken: + # 如果解密失败,返回空字符串 + return "" + + def load(self) -> bool: + """从JSON文件加载配置。如果文件不存在,则创建一个空的配置文件。""" + if not os.path.exists(self.file_path): + # 如果配置文件不存在,则创建一个空的配置字典并保存 + self.config = {} + self.save() + return True + try: + with open(self.file_path, 'r', encoding='utf-8') as f: + self.config = json.load(f) + return True + except (json.JSONDecodeError, IOError): + self.config = {} + return False + + def save(self): + """将当前配置保存到JSON文件。敏感信息的加密在`set`方法中处理。""" + try: + with open(self.file_path, 'w', encoding='utf-8') as f: + json.dump(self.config, f, indent=4) + except IOError as e: + print(f"保存配置失败: {e}") + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: + """获取指定键的配置值。如果键属于敏感信息,则自动解密后返回。""" + value = self.config.get(key) + if value is None: + return default + + if key in self.SENSITIVE_KEYS: + return self._decrypt(value) + return value + + def set(self, key: str, value: str): + """设置指定键的配置值。如果键属于敏感信息,则自动加密后存储。""" + if key in self.SENSITIVE_KEYS: + self.config[key] = self._encrypt(value) + else: + self.config[key] = value + + def check_settings(self) -> Tuple[bool, Optional[str]]: + """ + 检查所有必需的配置项是否都已设置。 + 返回一个元组,包含检查结果(布尔值)和第一个缺失的配置项名称(字符串)。 + """ + required_settings = { + "VlmUrl": "VLM服务地址", + "VlmApiKey": "VLM服务密钥", + "VlmModel": "VLM模型名称", + "LlmUrl": "LLM服务地址", + "LlmApiKey": "LLM服务密钥", + "LlmModel": "LLM模型名称", + } + for key, name in required_settings.items(): + # 使用self.get()来确保我们检查的是解密后的值 + if not self.get(key): + return False, name + return True, None diff --git a/main.py b/main.py new file mode 100644 index 0000000..232719f --- /dev/null +++ b/main.py @@ -0,0 +1,37 @@ +import tkinter as tk +from app_ui import MainApp +from config_manager import ConfigManager +from api_services import ApiService +import sys +import os + +def resource_path(relative_path): + """ + 获取资源的绝对路径,以支持PyInstaller打包后的单文件应用。 + + 在开发环境中,返回基于当前工作目录的相对路径。 + 在PyInstaller打包的应用中,返回临时文件夹`_MEIPASS`中的路径。 + """ + try: + # 尝试获取PyInstaller在运行时创建的临时路径 + base_path = sys._MEIPASS + except Exception: + # 如果`_MEIPASS`属性不存在,说明是在开发环境,使用当前目录 + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + +if __name__ == "__main__": + # 1. 初始化核心服务 + # 使用 resource_path 确保在打包后也能正确找到配置文件 + config_manager = ConfigManager(resource_path("config.json")) + api_service = ApiService(config_manager) + + # 2. 创建Tkinter主窗口 + root = tk.Tk() + + # 3. 实例化主应用,将服务注入应用 + app = MainApp(root, config_manager, api_service) + + # 4. 启动Tkinter事件循环 + root.mainloop() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff8425a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +cryptography +openai \ No newline at end of file