497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
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: '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')
|
|
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
|
|
)`).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()
|
|
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: 64 })
|
|
})
|
|
|
|
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)
|
|
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), 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 => ({
|
|
questionTitle: r.question_title,
|
|
maxScore: r.max_score,
|
|
referenceAnswer: r.reference_answer,
|
|
rubric: r.rubric,
|
|
referenceAnswerImage: r.reference_answer_image || 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 => ({
|
|
questionTitle: r.question_title,
|
|
maxScore: r.max_score,
|
|
referenceAnswer: r.reference_answer,
|
|
rubric: r.rubric,
|
|
referenceAnswerImage: r.reference_answer_image || 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 ? `【题目】\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 || '无'}
|
|
|
|
【参考答案】
|
|
${extras?.referenceAnswer || '无'}
|
|
|
|
【满分】
|
|
${extras?.maxScore ?? 100}
|
|
|
|
【学生答案】
|
|
请看图片中的内容。
|
|
|
|
---
|
|
|
|
【评分规则】
|
|
1. 根据评分标准中的各项指标逐项评分,每项给出得分和说明
|
|
2. 各评分项得分之和不得超过满分${extras?.maxScore ?? 100}分
|
|
3. 如果学生答案为空、空白或明显与题目无关,直接给0分
|
|
4. 部分正确时按评分标准酌情给分
|
|
|
|
【输出要求】
|
|
只输出以下JSON,不要包含markdown代码块、\`\`\`标记、注释或任何其他文字:
|
|
{
|
|
"score": <总分>,
|
|
"confidence": <置信度0~1>,
|
|
"deductions": ["扣分项说明1", "扣分项说明2"],
|
|
"comment": "总评语及逐项得分说明"
|
|
}`
|
|
|
|
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: [
|
|
{
|
|
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) {
|
|
log.error('AI API 返回为空或格式异常:', JSON.stringify(response.data).slice(0, 1000))
|
|
throw new Error(`API 返回为空,请检查模型名是否正确 (当前: ${config.model})`)
|
|
}
|
|
const parsed = tryParseJson(content)
|
|
if (!parsed) {
|
|
log.error('AI 返回 JSON 解析失败:', content.slice(0, 500))
|
|
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
|
|
}
|