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

366 lines
10 KiB
TypeScript

import { BrowserWindow, ipcMain, dialog as dialogBox } 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: 'gpt-4o',
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')
db = new Database('grading.db')
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
)`).run()
db.prepare(`CREATE TABLE IF NOT EXISTS exam (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
course TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`).run()
db.prepare(`CREATE TABLE IF NOT EXISTS question (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exam_id INTEGER,
title TEXT,
max_score INTEGER,
rubric TEXT
)`).run()
}
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('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 }: {
imageBase64: string
rubric: string
apiConfig: { apiKey: string; baseUrl: string; model: string }
maxScore?: number
referenceAnswer?: string
questionTitle?: string
}) => {
log.info('IPC: ai:grade')
try {
const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle })
return result
} catch (err: any) {
log.error('AI grade failed:', err)
throw new Error(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('grading:submit', (_event, data: {
questionId: number
imagePath: string
aiScore: number
teacherScore: number
confidence: number
studentAnswer?: string
}) => {
try {
getDb().prepare(`INSERT INTO history (question_id, image_path, student_answer, ai_score, teacher_score, confidence)
VALUES (?, ?, ?, ?, ?, ?)`).run(
data.questionId || 0,
data.imagePath || '',
data.studentAnswer || '',
data.aiScore,
data.teacherScore,
data.confidence
)
return true
} catch (err) {
log.error('grading:submit error:', err)
return false
}
})
ipcMain.handle('template:list', () => {
try {
const db = getDb()
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
return exams
} catch {
return []
}
})
ipcMain.handle('template:save', (_event, data: { name: string; course: string }) => {
try {
const db = getDb()
const result = db.prepare('INSERT INTO exam (name, course) VALUES (?, ?)').run(data.name, data.course)
return result.lastInsertRowid
} catch (err) {
log.error('template:save error:', err)
return null
}
})
// --- 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) ---
ipcMain.handle('rubric:storeData', (_event, data: any) => {
rubricPayload = data
})
ipcMain.handle('rubric:getData', () => rubricPayload)
// --- 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 rubricPayload: any = null
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<{
score: number
confidence: number
deductions: string[]
comment: string
}> {
const systemRole = `你是一名经验丰富的高校教师。你必须严格依据评分标准逐项评分。
【输出要求】
1. 仅输出纯JSON,不要包含任何markdown代码块、\`\`\`标记、或额外文字
2. 不得在JSON前后添加任何说明、注释或分隔符
3. 字符串必须使用双引号,不得使用单引号
4. 不得有多余的逗号
【输出格式】
{
"score": 分数,
"confidence": 置信度(0-1),
"deductions": ["扣分项1", "扣分项2"],
"comment": "总评语及逐项得分说明"
}`
const prompt = `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】
${rubric}
【参考答案】
${extras?.referenceAnswer || '无'}
【总分】
${extras?.maxScore ?? 100}
【学生答案】
请看图片中的内容。`
const axios = require('axios')
const response = await axios.post(`${config.baseUrl}/chat/completions`, {
model: config.model,
messages: [
{
role: 'system',
content: systemRole
},
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
{ type: 'text', text: prompt }
]
}
],
max_tokens: 2048,
temperature: 0.1
}, {
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
const parsed = tryParseJson(content)
if (!parsed) {
throw new Error(`AI 返回格式异常,无法解析为 JSON。原始返回:\n${content}`)
}
return {
score: parsed.score,
confidence: parsed.confidence ?? 0.5,
deductions: parsed.deductions ?? [],
comment: parsed.comment ?? parsed.commit ?? ''
}
}
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
}