643 lines
22 KiB
TypeScript
643 lines
22 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: '',
|
||
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
|
||
}
|