Initial commit
This commit is contained in:
58
.github/workflows/build-windows.yml
vendored
Normal file
58
.github/workflows/build-windows.yml
vendored
Normal file
@@ -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
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -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
|
||||||
163
api_services.py
Normal file
163
api_services.py
Normal file
@@ -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 `<wscore>` and the full `<text>` 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>{wscore}</wscore>
|
||||||
|
<text>
|
||||||
|
{essay_text}
|
||||||
|
</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 <wscore> 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 <text> XML tag.
|
||||||
|
# OUTPUT FORMAT
|
||||||
|
Strictly adhere to the following format. Do not output anything else.
|
||||||
|
<wscore>[Your calculated score, e.g., 0.85]</wscore>
|
||||||
|
<text>
|
||||||
|
[The full extracted text from the image goes here.]
|
||||||
|
</text>"""
|
||||||
|
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'<wscore>(.*?)</wscore>', vlm_output, re.DOTALL)
|
||||||
|
text_match = re.search(r'<text>(.*?)</text>', 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
|
||||||
387
app_ui.py
Normal file
387
app_ui.py
Normal file
@@ -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("<FocusIn>", on_focus_in)
|
||||||
|
self.topic_input.bind("<FocusOut>", 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))
|
||||||
107
config_manager.py
Normal file
107
config_manager.py
Normal file
@@ -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
|
||||||
37
main.py
Normal file
37
main.py
Normal file
@@ -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()
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
cryptography
|
||||||
|
openai
|
||||||
Reference in New Issue
Block a user