Files
AI-Grading-Assistant/electron/main/ipc.ts
T

643 lines
22 KiB
TypeScript
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 { BrowserWindow, ipcMain, dialog as dialogBox, app } from 'electron'
import { captureScreen, closeScreenshot, getCapturedImage, getCapturedBase64, clearCapturedImage, startScreenshot } from './screenshot'
import { getMainWindow, openDialogWindow } from './window'
import * as fs from 'fs'
import * as path from 'path'
import log from 'electron-log'
let store: any = null
function getStore() {
if (!store) {
const Store = require('electron-store').default
store = new Store({
defaults: {
apiKey: '',
baseUrl: 'https://api.siliconflow.cn/v1',
model: '',
shortcut: 'Alt+Q',
theme: 'light',
fontSize: 14,
proxy: '',
timeout: 120,
screenshotSavePath: ''
}
})
// migrate old low timeout values
const t = store.get('timeout') as number | undefined
if (t !== undefined && t < 60) store.set('timeout', 120)
}
return store
}
let db: any = null
function getDb() {
if (!db) {
const Database = require('better-sqlite3')
const dbPath = require('path').join(app.getPath('userData'), 'grading.db')
db = new Database(dbPath)
db.prepare(`CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER,
image_path TEXT,
student_answer TEXT,
ai_score REAL,
teacher_score REAL,
confidence REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
question_title TEXT DEFAULT '',
max_score REAL DEFAULT 100,
reference_answer TEXT DEFAULT '',
rubric TEXT DEFAULT '',
deductions TEXT DEFAULT '',
comment TEXT DEFAULT '',
result_json TEXT DEFAULT '',
image_data TEXT DEFAULT ''
)`).run()
// migrate: add columns for existing DBs
const migrateCols: { name: string; type: string }[] = [
{ name: 'question_title', type: 'TEXT DEFAULT \'\'' },
{ name: 'max_score', type: 'REAL DEFAULT 100' },
{ name: 'reference_answer', type: 'TEXT DEFAULT \'\'' },
{ name: 'rubric', type: 'TEXT DEFAULT \'\'' },
{ name: 'deductions', type: 'TEXT DEFAULT \'\'' },
{ name: 'comment', type: 'TEXT DEFAULT \'\'' },
{ name: 'result_json', type: 'TEXT DEFAULT \'\'' },
{ name: 'image_data', type: 'TEXT DEFAULT \'\'' },
{ name: 'reference_answer_image', type: 'TEXT DEFAULT \'\'' }
]
for (const col of migrateCols) {
try { db.prepare(`ALTER TABLE history ADD COLUMN ${col.name} ${col.type}`).run() } catch (_) {}
}
db.prepare(`CREATE TABLE IF NOT EXISTS exam (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
course TEXT,
questions_json TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`).run()
try { db.prepare(`ALTER TABLE exam ADD COLUMN questions_json TEXT DEFAULT ''`).run() } catch (_) {}
db.prepare(`CREATE TABLE IF NOT EXISTS question (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exam_id INTEGER,
title TEXT,
max_score INTEGER,
rubric TEXT
)`).run()
db.prepare(`CREATE TABLE IF NOT EXISTS rubric (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_title TEXT NOT NULL DEFAULT '',
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,
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
}
export function setupIpc(win: BrowserWindow): void {
ipcMain.handle('window:dragMove', (_event, { deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
const [x, y] = win.getPosition()
win.setPosition(x + deltaX, y + deltaY)
})
ipcMain.handle('window:dragEnd', (_event, { x, y }: { x: number; y: number }) => {
win.setPosition(x, y)
})
ipcMain.handle('window:resizeMain', (_event, width: number) => {
const [wx, wy] = win.getPosition()
win.setBounds({ x: wx, y: wy, width: Math.max(200, Math.min(width, 1200)), height: 76 })
})
ipcMain.handle('screenshot:start', () => {
log.info('IPC: screenshot:start')
startScreenshot(win)
})
ipcMain.handle('screenshot:capture', async (_event, region: { x: number; y: number; width: number; height: number }) => {
log.info('IPC: screenshot:capture', region)
const base64 = await captureScreen(region)
closeScreenshot()
setTimeout(() => {
const mainWin = getMainWindow()
if (mainWin) {
mainWin.show()
mainWin.webContents.send('screenshot:completed', base64)
}
require('./shortcut').enableShortcut()
}, 100)
return base64
})
ipcMain.handle('screenshot:getLastImage', () => {
return getCapturedBase64()
})
ipcMain.handle('screenshot:cancel', () => {
log.info('IPC: screenshot:cancel')
closeScreenshot()
setTimeout(() => {
const mainWin = getMainWindow()
if (mainWin) mainWin.show()
require('./shortcut').enableShortcut()
}, 100)
})
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, referenceAnswerImage })
return result
} catch (err: any) {
log.error('AI grade failed:', err)
const res = err?.response?.data
const apiMsg = res?.error?.message || res?.error || res?.message || ''
const allMsg = (apiMsg + ' ' + (err.message || '')).toLowerCase()
if (allMsg.includes('does not support image') || allMsg.includes('image input') || allMsg.includes('not support image')) {
throw new Error('当前模型不支持图片输入,请在设置中更换为支持多模态的视觉模型')
}
throw new Error(apiMsg || err.message || 'AI grading failed')
}
})
ipcMain.handle('settings:get', () => {
return getStore().store
})
ipcMain.handle('settings:set', (_event, settings: Record<string, unknown>) => {
const s = getStore()
for (const [key, value] of Object.entries(settings)) {
s.set(key, value)
}
return true
})
ipcMain.handle('settings:selectDirectory', async () => {
const result = await dialogBox.showOpenDialog({
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]
})
ipcMain.handle('history:list', () => {
try {
return getDb().prepare('SELECT * FROM history ORDER BY created_at DESC LIMIT 100').all()
} catch (err) {
log.error('history:list error:', err)
return []
}
})
ipcMain.handle('history:delete', (_event, id: number) => {
try {
getDb().prepare('DELETE FROM history WHERE id = ?').run(id)
return true
} catch {
return false
}
})
ipcMain.handle('history:getById', (_event, id: number) => {
try {
const record = getDb().prepare('SELECT * FROM history WHERE id = ?').get(id) as any
return record || null
} catch {
return null
}
})
ipcMain.handle('grading:submit', (_event, data: {
questionId: number
imagePath: string
aiScore: number
teacherScore: number
confidence: number
studentAnswer?: string
questionTitle?: string
maxScore?: number
referenceAnswer?: string
rubric?: string
deductions?: string[]
comment?: string
resultJson?: string
imageData?: string
referenceAnswerImage?: string
}) => {
log.info('grading:submit called', JSON.stringify({ ...data, imageData: data.imageData ? `len=${data.imageData.length}` : 'empty', referenceAnswerImage: data.referenceAnswerImage ? `len=${data.referenceAnswerImage.length}` : 'empty' }))
const deductionsStr = JSON.stringify(data.deductions || [])
getDb().prepare(`INSERT INTO history
(question_id, image_path, student_answer, ai_score, teacher_score, confidence,
question_title, max_score, reference_answer, rubric, deductions, comment, result_json, image_data,
reference_answer_image)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
data.questionId || 0,
data.imagePath || '',
data.studentAnswer || '',
data.aiScore,
data.teacherScore,
data.confidence,
data.questionTitle || '',
data.maxScore ?? 100,
data.referenceAnswer || '',
data.rubric || '',
deductionsStr,
data.comment || '',
data.resultJson || '',
data.imageData || '',
data.referenceAnswerImage || ''
)
return true
})
ipcMain.handle('template:list', () => {
try {
const db = getDb()
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all() as any[]
const result = exams.map((e: any) => {
const questions = e.questions_json ? JSON.parse(e.questions_json) : []
log.info('template:list item', { id: e.id, name: e.name, jsonLen: (e.questions_json || '').length, questionCount: questions.length })
return { ...e, questions }
})
return result
} catch {
return []
}
})
ipcMain.handle('template:exportJson', async () => {
try {
const d = getDb()
const rows: any[] = d.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
if (!rows.length) throw new Error('暂无模板可导出')
const templates = rows.map(r => ({
name: r.name,
course: r.course,
questions: r.questions_json ? JSON.parse(r.questions_json) : [],
created_at: r.created_at
}))
const result = await dialogBox.showSaveDialog({
defaultPath: '评分模板.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return false
fs.writeFileSync(result.filePath, JSON.stringify(templates, null, 2), 'utf-8')
return true
} catch (err) {
log.error('template:exportJson error:', err)
return false
}
})
ipcMain.handle('template:importJson', async () => {
try {
const result = await dialogBox.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) return null
const content = fs.readFileSync(result.filePaths[0], 'utf-8')
const data = JSON.parse(content)
if (!Array.isArray(data)) throw new Error('JSON 格式错误:应为数组')
const db = getDb()
const insert = db.prepare('INSERT INTO exam (name, course, questions_json) VALUES (?, ?, ?)')
let count = 0
for (const tpl of data) {
if (tpl.name && tpl.course) {
insert.run(tpl.name, tpl.course, JSON.stringify(tpl.questions || []))
count++
}
}
return { count }
} catch (err) {
log.error('template:importJson error:', err)
return null
}
})
ipcMain.handle('template:save', (_event, data: { name: string; course: string; questions?: any[] }) => {
try {
const db = getDb()
const questionsJson = JSON.stringify(data.questions || [])
log.info('template:save', { name: data.name, course: data.course, questionCount: data.questions?.length ?? 0, jsonLen: questionsJson.length })
const result = db.prepare('INSERT INTO exam (name, course, questions_json) VALUES (?, ?, ?)').run(data.name, data.course, questionsJson)
return result.lastInsertRowid
} catch (err) {
log.error('template:save error:', err)
return null
}
})
ipcMain.handle('template:delete', (_event, id: number) => {
try {
getDb().prepare('DELETE FROM exam WHERE id = ?').run(id)
return true
} catch {
return false
}
})
ipcMain.handle('template:apply', (_event, id: number) => {
try {
const db = getDb()
const row = db.prepare('SELECT * FROM exam WHERE id = ?').get(id) as any
if (!row) throw new Error('模板不存在')
const questions = row.questions_json ? JSON.parse(row.questions_json) : []
if (!questions.length) throw new Error('模板中没有题目')
db.prepare('DELETE FROM rubric').run()
const insert = db.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, q.referenceAnswerImage || '')
}
rubricNextIndex = 0
const mainWin = getMainWindow()
if (mainWin) mainWin.webContents.send('rubric:updated')
return true
} catch (err) {
log.error('template:apply error:', err)
return false
}
})
// --- Dialog child windows ---
ipcMain.handle('dialog:open', (_event, { type, data }: { type: string; data?: any }) => {
log.info('IPC: dialog:open', type)
if (data !== undefined) {
dialogPayloads[type] = data
}
openDialogWindow(type)
})
ipcMain.on('dialog:close', (_event) => {
const w = BrowserWindow.fromWebContents(_event.sender)
if (w) w.close()
})
ipcMain.handle('dialog:getPayload', (_event, type: string) => {
const data = dialogPayloads[type]
delete dialogPayloads[type]
return data
})
// --- Open image file from disk ---
ipcMain.handle('dialog:openImage', async () => {
const result = await dialogBox.showOpenDialog({
properties: ['openFile'],
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'] }]
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const ext = path.extname(filePath).toLowerCase().replace('.', '')
const mime = ext === 'jpg' ? 'jpeg' : ext
const buffer = fs.readFileSync(filePath)
return { base64: buffer.toString('base64'), mime, filePath }
})
// --- Rubric data bridge (toolbar ↔ rubric window), persisted to DB ---
ipcMain.handle('rubric:storeData', (_event, data: any) => {
const d = getDb()
const questions = data?.questions || []
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, 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, q.referenceAnswerImage || '')
}
})
ipcMain.handle('rubric:getData', () => {
const d = getDb()
const rows: any[] = d.prepare('SELECT * FROM rubric ORDER BY sort_order').all()
log.info(`rubric:getData: ${rows.length} rows`)
if (rows.length) {
return {
questions: rows.map(r => {
const img = r.reference_answer_image
return {
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric,
referenceAnswerImage: img && !img.startsWith('data:') ? `data:image/png;base64,${img}` : (img || undefined)
}
}),
currentIndex: 0
}
}
return null
})
ipcMain.handle('rubric:exportJson', async () => {
const d = getDb()
const rows: any[] = d.prepare('SELECT * FROM rubric ORDER BY sort_order').all()
if (!rows.length) throw new Error('暂无评分标准可导出')
const questions = rows.map(r => {
const img = r.reference_answer_image
return {
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric,
referenceAnswerImage: img && !img.startsWith('data:') ? `data:image/png;base64,${img}` : (img || undefined)
}
})
const result = await dialogBox.showSaveDialog({
defaultPath: '评分标准.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return false
fs.writeFileSync(result.filePath, JSON.stringify(questions, null, 2), 'utf-8')
return true
})
ipcMain.handle('rubric:importJson', async () => {
const result = await dialogBox.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) return null
const content = fs.readFileSync(result.filePaths[0], 'utf-8')
const questions = JSON.parse(content)
if (!Array.isArray(questions)) throw new Error('JSON 格式错误:应为数组')
return { questions, currentIndex: 0 }
})
// --- Rubric next question index (persisted between grading cycles) ---
let rubricNextIndex = 0
ipcMain.handle('rubric:getNextIndex', () => rubricNextIndex)
ipcMain.handle('rubric:setNextIndex', (_event, idx: number) => { rubricNextIndex = idx })
ipcMain.handle('rubric:resetNextIndex', () => { rubricNextIndex = 0 })
// --- Grading data bridge (toolbar → result window) ---
ipcMain.handle('grading:storeData', (_event, data: any) => {
gradingPayload = data
})
ipcMain.handle('grading:getData', () => gradingPayload)
}
const dialogPayloads: Record<string, any> = {}
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; referenceAnswerImage?: string }): Promise<{
score: number
confidence: number
deductions: string[]
comment: string
}> {
const systemRole = `你是一名高校阅卷老师,根据评分标准和答案对学生答案评分。请输出JSON格式的结果。`
const hasRefImage = !!extras?.referenceAnswerImage
const prompt = hasRefImage
? `题目:${extras?.questionTitle || ''}
评分标准:${rubric || '无'}
参考答案:${extras?.referenceAnswer || '无'}
满分:${extras?.maxScore ?? 100}
图片1是标准答案,图片2是学生答案。
请根据评分标准和标准答案(图片1)给学生答案(图片2)评分。
评分规则:
1. 逐项评分,各项得分之和不超过满分${extras?.maxScore ?? 100}
2. 空白或无关答案给0分
3. 部分正确酌情给分
请输出JSON
{"score":分数,"confidence":置信度0~1,"deductions":["扣分项"],"comment":"评语"}
只输出JSON,不要包含其他文字和markdown标记。`
: `题目:${extras?.questionTitle || ''}
评分标准:${rubric || '无'}
参考答案:${extras?.referenceAnswer || '无'}
满分:${extras?.maxScore ?? 100}
图片是学生答案,请根据评分标准评分。
评分规则:
1. 逐项评分,各项得分之和不超过满分${extras?.maxScore ?? 100}
2. 空白或无关答案给0分
3. 部分正确酌情给分
请输出JSON
{"score":分数,"confidence":置信度0~1,"deductions":["扣分项"],"comment":"评语"}
只输出JSON,不要包含其他文字和markdown标记。`
const axios = require('axios')
const userContent: any[] = hasRefImage
? [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: extras!.referenceAnswerImage } },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }
]
: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }
]
let lastError: Error | null = null
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await axios.post(`${config.baseUrl}/chat/completions`, {
model: config.model,
messages: [
{
role: 'system',
content: systemRole
},
{
role: 'user',
content: userContent
}
],
max_tokens: 4096,
temperature: 0.3
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
timeout: ((getStore().get('timeout') as number) ?? 120) * 1000
})
const content = response.data?.choices?.[0]?.message?.content
if (!content) {
lastError = new Error(`API 返回为空 (尝试 ${attempt}/3)`)
log.warn(`AI API empty response, attempt ${attempt}/3`)
if (attempt < 3) continue
throw lastError
}
const parsed = tryParseJson(content)
if (!parsed) {
lastError = new Error(`AI 返回 JSON 解析失败 (尝试 ${attempt}/3)`)
log.warn(`AI JSON parse failed, attempt ${attempt}/3:`, content.slice(0, 200))
if (attempt < 3) continue
throw lastError
}
return {
score: parsed.score,
confidence: parsed.confidence ?? 0.5,
deductions: parsed.deductions ?? [],
comment: parsed.comment ?? parsed.commit ?? ''
}
} catch (err: any) {
lastError = err
if (attempt < 3) {
log.info(`AI API attempt ${attempt}/3 failed, retrying...`)
await new Promise(r => setTimeout(r, 2000))
}
}
}
throw lastError || new Error('AI API 调用失败')
}
function tryParseJson(text: string): any {
let cleaned = text.trim()
// remove markdown code fences
cleaned = cleaned.replace(/```(?:json)?\s*/gi, '').replace(/```\s*/g, '').trim()
// if there's still non-JSON text, extract the first {...} object
if (!cleaned.startsWith('{') && !cleaned.startsWith('[')) {
const match = cleaned.match(/\{[\s\S]*\}/)
if (match) cleaned = match[0]
}
// attempt direct parse
try {
return JSON.parse(cleaned)
} catch {}
// fix single quotes -> double quotes (but keep escaped ones)
try {
const fixed = cleaned
.replace(/(?<!\\)'/g, '"')
.replace(/,(\s*[}\]])/g, '$1') // trailing commas
return JSON.parse(fixed)
} catch {}
// fix unquoted keys
try {
const fixed = cleaned
.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3')
.replace(/,(\s*[}\]])/g, '$1')
return JSON.parse(fixed)
} catch {}
return null
}