From 25b8cfb787144d47e6ac8ee54927071ff1208b5d Mon Sep 17 00:00:00 2001 From: brianling Date: Thu, 16 Jul 2026 10:07:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=82=E8=80=83=E7=AD=94=E6=A1=88?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=20+=20AI=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=88=86=E6=9C=89=E6=97=A0=E6=A0=87=E5=87=86=E7=AD=94?= =?UTF-8?q?=E6=A1=88=E5=9B=BE=E7=89=87=E4=B8=A4=E7=A7=8D=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- electron/main/ipc.ts | 74 ++++++++++++++++++++++++------ src/components/FloatingToolbar.vue | 1 + src/components/ResultDrawer.vue | 9 ++++ src/components/RubricEditor.vue | 38 +++++++++++++++ src/types/index.ts | 3 +- 6 files changed, 113 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 16c4eca..3bcf7bf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ build-release/ dist-release/ out-electron/ out-portable/ -packaged/ \ No newline at end of file +packaged/ +test/ \ No newline at end of file diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index aa5e5e9..5c277e7 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -64,8 +64,11 @@ function getDb() { max_score REAL NOT NULL DEFAULT 100, reference_answer TEXT NOT NULL DEFAULT '', rubric TEXT NOT NULL DEFAULT '', - sort_order INTEGER NOT NULL DEFAULT 0 + sort_order INTEGER NOT NULL DEFAULT 0, + reference_answer_image TEXT NOT NULL DEFAULT '' )`).run() + // migrate: add column for existing DBs + try { db.prepare('ALTER TABLE rubric ADD COLUMN reference_answer_image TEXT NOT NULL DEFAULT \'\'').run() } catch (_) {} } return db } @@ -120,17 +123,18 @@ export function setupIpc(win: BrowserWindow): void { }, 100) }) - ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle }: { + ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle, referenceAnswerImage }: { imageBase64: string rubric: string apiConfig: { apiKey: string; baseUrl: string; model: string } maxScore?: number referenceAnswer?: string questionTitle?: string + referenceAnswerImage?: string }) => { log.info('IPC: ai:grade') try { - const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle }) + const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle, referenceAnswerImage }) return result } catch (err: any) { log.error('AI grade failed:', err) @@ -263,10 +267,10 @@ export function setupIpc(win: BrowserWindow): void { log.info(`rubric:storeData: ${questions.length} questions`, JSON.stringify(questions.map((q: any) => ({ title: q.questionTitle, rubricLen: q.rubric?.length })))) if (questions.length === 0) return d.prepare('DELETE FROM rubric').run() - const insert = d.prepare('INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order) VALUES (?, ?, ?, ?, ?)') + const insert = d.prepare('INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order, reference_answer_image) VALUES (?, ?, ?, ?, ?, ?)') for (let i = 0; i < questions.length; i++) { const q = questions[i] - insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i) + insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i, q.referenceAnswerImage || '') } }) @@ -280,7 +284,8 @@ export function setupIpc(win: BrowserWindow): void { questionTitle: r.question_title, maxScore: r.max_score, referenceAnswer: r.reference_answer, - rubric: r.rubric + rubric: r.rubric, + referenceAnswerImage: r.reference_answer_image || undefined })), currentIndex: 0 } @@ -296,7 +301,8 @@ export function setupIpc(win: BrowserWindow): void { questionTitle: r.question_title, maxScore: r.max_score, referenceAnswer: r.reference_answer, - rubric: r.rubric + rubric: r.rubric, + referenceAnswerImage: r.reference_answer_image || undefined })) const result = await dialogBox.showSaveDialog({ defaultPath: '评分标准.json', @@ -336,7 +342,7 @@ export function setupIpc(win: BrowserWindow): void { const dialogPayloads: Record = {} let gradingPayload: any = null -async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string }): Promise<{ +async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }): Promise<{ score: number confidence: number deductions: string[] @@ -344,7 +350,40 @@ async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: }> { const systemRole = `你是高校阅卷专家,严格按照评分标准逐项评分。只输出JSON,不包含任何其他文字。` - const prompt = `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】 + const hasRefImage = !!extras?.referenceAnswerImage + + const prompt = hasRefImage + ? `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】 +${rubric || '无'} + +【参考答案】 +${extras?.referenceAnswer || '无'} + +【满分】 +${extras?.maxScore ?? 100} + +图片1:学生答案 +图片2:标准答案 + +请按照标准答案(图片2)给学生答案(图片1)评分。 + +--- + +【评分规则】 +1. 根据评分标准中的各项指标逐项评分,每项给出得分和说明 +2. 各评分项得分之和不得超过满分${extras?.maxScore ?? 100}分 +3. 如果学生答案为空、空白或明显与题目无关,直接给0分 +4. 部分正确时按评分标准酌情给分 + +【输出要求】 +只输出以下JSON,不要包含markdown代码块、\`\`\`标记、注释或任何其他文字: +{ + "score": <总分>, + "confidence": <置信度0~1>, + "deductions": ["扣分项说明1", "扣分项说明2"], + "comment": "总评语及逐项得分说明" +}` + : `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】 ${rubric || '无'} 【参考答案】 @@ -374,6 +413,18 @@ ${extras?.maxScore ?? 100} }` const axios = require('axios') + + const userContent: any[] = hasRefImage + ? [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${extras!.referenceAnswerImage}` } } + ] + : [ + { type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }, + { type: 'text', text: prompt } + ] + const response = await axios.post(`${config.baseUrl}/chat/completions`, { model: config.model, messages: [ @@ -383,10 +434,7 @@ ${extras?.maxScore ?? 100} }, { role: 'user', - content: [ - { type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }, - { type: 'text', text: prompt } - ] + content: userContent } ], max_tokens: 4096, diff --git a/src/components/FloatingToolbar.vue b/src/components/FloatingToolbar.vue index 8cc5a2e..d8c36c3 100644 --- a/src/components/FloatingToolbar.vue +++ b/src/components/FloatingToolbar.vue @@ -113,6 +113,7 @@ async function gradeQuestion(questionIdx: number) { rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}分`, maxScore: q.maxScore, referenceAnswer: q.referenceAnswer, + referenceAnswerImage: q.referenceAnswerImage, questionTitle: q.questionTitle, apiConfig: { apiKey: settingsStore.settings.apiKey, diff --git a/src/components/ResultDrawer.vue b/src/components/ResultDrawer.vue index b3e6c7e..694da93 100644 --- a/src/components/ResultDrawer.vue +++ b/src/components/ResultDrawer.vue @@ -28,6 +28,7 @@
参考答案
{{ currentQuestion.referenceAnswer }}
+
@@ -143,6 +144,7 @@ async function gradeAt(idx: number) { rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}分`, maxScore: q.maxScore, referenceAnswer: q.referenceAnswer, + referenceAnswerImage: q.referenceAnswerImage, questionTitle: q.questionTitle, apiConfig: { apiKey: settingsStore.settings.apiKey, @@ -353,6 +355,13 @@ async function confirmScore() { border-radius: 6px; white-space: pre-wrap; } +.ref-image { + max-width: 100%; + max-height: 300px; + border-radius: 6px; + margin-top: 8px; + border: 1px solid #e4e7ed; +} .error-message { background: #fef0f0; color: #f56c6c; diff --git a/src/components/RubricEditor.vue b/src/components/RubricEditor.vue index 12754a0..ae24450 100644 --- a/src/components/RubricEditor.vue +++ b/src/components/RubricEditor.vue @@ -25,6 +25,15 @@ +
+ + 上传参考答案图片 +
@@ -128,6 +137,18 @@ async function handleImport() { function handleClose() { window.electronAPI?.dialog.close() } + +async function uploadRefImage(idx: number) { + if (!window.electronAPI) return + const data = await window.electronAPI.dialog.openImage() + if (data) { + questions.value[idx].referenceAnswerImage = data.base64 + } +} + +function removeRefImage(idx: number) { + delete questions.value[idx].referenceAnswerImage +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index e375f89..51e7b8a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export interface QuestionItem { maxScore: number referenceAnswer: string rubric: string + referenceAnswerImage?: string } export interface GradeResult { @@ -56,7 +57,7 @@ export interface ElectronAPI { onCompleted: (callback: (base64: string) => void) => () => void } ai: { - grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string }) => Promise + grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }) => Promise } settings: { get: () => Promise