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