feat: 添加 Gemini 支持和 AI 参考答案生成功能

主要功能:
- 🎯 新增 Google Gemini AI 提供商支持
  - 原生 PDF 理解能力(最多1000页)
  - 完整保留图片、表格、公式等内容
  - 支持自定义 Base URL(用于代理/中转服务)

- 🤖 实现 AI 参考答案自动生成
  - 当题目缺少答案时自动调用 AI 生成参考答案
  - 支持单选、多选、判断、简答等所有题型
  - 答案标记为"AI参考答案:"便于识别

- 🔧 优化文档解析功能
  - 改进中文 Prompt 提高识别准确度
  - 自动修复 JSON 中的控制字符(换行符等)
  - 智能题目类型验证和自动转换(proof→short等)
  - 增加超时时间和重试机制

- 🎨 完善管理后台配置界面
  - 新增 Gemini 配置区域
  - 突出显示 PDF 原生支持特性
  - 为其他提供商添加"仅文本"警告
  - 支持 Gemini Base URL 自定义

技术改进:
- 添加 google-genai 依赖
- 实现异步 API 调用适配
- 完善错误处理和日志输出
- 统一配置管理和数据库存储

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 22:43:08 +08:00
parent a01f3540c5
commit d24a1a1f92
7 changed files with 824 additions and 63 deletions

View File

@@ -2,26 +2,29 @@
* Admin Settings Page - Enhanced with API Configuration
*/
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminAPI } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff } from 'lucide-react'
import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react'
import toast from 'react-hot-toast'
export const AdminSettings = () => {
const { user } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [showApiKeys, setShowApiKeys] = useState({
openai: false,
anthropic: false,
qwen: false
qwen: false,
gemini: false
})
const [config, setConfig] = useState({
allow_registration: true,
max_upload_size_mb: 10,
max_daily_uploads: 20,
ai_provider: 'openai',
ai_provider: 'gemini',
// OpenAI
openai_api_key: '',
openai_base_url: 'https://api.openai.com/v1',
@@ -32,7 +35,11 @@ export const AdminSettings = () => {
// Qwen
qwen_api_key: '',
qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
qwen_model: 'qwen-plus'
qwen_model: 'qwen-plus',
// Gemini
gemini_api_key: '',
gemini_base_url: '',
gemini_model: 'gemini-2.0-flash-exp'
})
useEffect(() => {
@@ -114,11 +121,20 @@ export const AdminSettings = () => {
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3">
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回"
>
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
</div>
</div>
</div>
</div>
@@ -189,12 +205,13 @@ export const AdminSettings = () => {
onChange={(e) => handleChange('ai_provider', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="gemini">Google Gemini (推荐)</option>
<option value="openai">OpenAI (GPT)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="qwen">Qwen (通义千问)</option>
</select>
<p className="text-sm text-gray-500 mt-1">
选择后在下方配置对应的 API 密钥
选择后在下方配置对应的 API 密钥Gemini 支持原生 PDF 解析
</p>
</div>
</div>
@@ -209,6 +226,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
OpenAI 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -258,16 +282,21 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="openai-models"
value={config.openai_model}
onChange={(e) => handleChange('openai_model', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
placeholder="gpt-4o-mini"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="openai-models">
<option value="gpt-4o">gpt-4o (最强)</option>
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
</select>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
@@ -281,6 +310,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
Anthropic 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -329,15 +365,21 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="anthropic-models"
value={config.anthropic_model}
onChange={(e) => handleChange('anthropic_model', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
placeholder="claude-3-haiku-20240307"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="anthropic-models">
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
<option value="claude-3-opus-20240229">claude-3-opus</option>
</select>
<option value="claude-3-sonnet-20240229">claude-3-sonnet</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
@@ -351,6 +393,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
通义千问 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -400,15 +449,105 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="qwen-models"
value={config.qwen_model}
onChange={(e) => handleChange('qwen_model', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
placeholder="qwen-plus"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="qwen-models">
<option value="qwen-max">qwen-max (最强)</option>
<option value="qwen-plus">qwen-plus (推荐)</option>
<option value="qwen-turbo">qwen-turbo</option>
</select>
<option value="qwen-turbo">qwen-turbo (快速)</option>
<option value="qwen-long">qwen-long (长文本)</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Gemini Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'gemini' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-bold text-gray-900">Google Gemini 配置</h2>
{config.ai_provider === 'gemini' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* PDF support highlight */}
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
Gemini 支持原生 PDF 理解可直接处理 PDF 文件最多 1000 完整保留图片表格公式等内容
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.gemini ? 'text' : 'password'}
value={config.gemini_api_key || ''}
onChange={(e) => handleChange('gemini_api_key', e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('gemini')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.gemini ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://aistudio.google.com/apikey 获取</p>
</div>
{/* Base URL (optional) */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL (可选)
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={config.gemini_base_url}
onChange={(e) => handleChange('gemini_base_url', e.target.value)}
placeholder="https://generativelanguage.googleapis.com留空使用默认"
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
可配置自定义代理或中转服务支持 Key 轮训等留空则使用 Google 官方 API
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="gemini-models"
value={config.gemini_model}
onChange={(e) => handleChange('gemini_model', e.target.value)}
placeholder="gemini-2.0-flash-exp"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="gemini-models">
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (最新推荐)</option>
<option value="gemini-1.5-pro">gemini-1.5-pro (最强)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash (快速)</option>
<option value="gemini-1.0-pro">gemini-1.0-pro</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>