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) => { 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 = {} 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(/(?