Спринт 4: AI-помощник на базе DeepSeek
- Страница /settings: ввод и проверка API ключа DeepSeek - POST /api/llm/generate — генерация вопросов по названию теста - POST /api/llm/improve — улучшение формулировки вопроса + ответов (модал с галочками) - POST /api/llm/distractors — генерация дистракторов - POST /api/llm/review — рецензия теста + кнопка «Предложить вариант» - POST /api/llm/improve_all — улучшение всего теста с постатейным сравнением - Миграция 004: таблица settings (key-value) - Шапка приложения с навигацией на /settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+51
-10
@@ -1,30 +1,71 @@
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import { Button, ConfigProvider, Layout } from 'antd'
|
||||
import ruRU from 'antd/locale/ru_RU'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
|
||||
|
||||
import AttemptResult from './pages/AttemptResult'
|
||||
import Settings from './pages/Settings'
|
||||
import TestCreate from './pages/TestCreate'
|
||||
import TestDetail from './pages/TestDetail'
|
||||
import TestEdit from './pages/TestEdit'
|
||||
import TestList from './pages/TestList'
|
||||
import TestTake from './pages/TestTake'
|
||||
|
||||
const { Header, Content } = Layout
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function AppHeader() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '0 24px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ fontWeight: 700, fontSize: 16, cursor: 'pointer' }}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
QA Test App
|
||||
</span>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type="text"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Настройки"
|
||||
>
|
||||
Настройки
|
||||
</Button>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={ruRU}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<TestList />} />
|
||||
<Route path="/tests/create" element={<TestCreate />} />
|
||||
<Route path="/tests/:id" element={<TestDetail />} />
|
||||
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||
</Routes>
|
||||
<Layout style={{ minHeight: '100vh', background: '#f5f5f5' }}>
|
||||
<AppHeader />
|
||||
<Content>
|
||||
<Routes>
|
||||
<Route path="/" element={<TestList />} />
|
||||
<Route path="/tests/create" element={<TestCreate />} />
|
||||
<Route path="/tests/:id" element={<TestDetail />} />
|
||||
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface LLMQuestion {
|
||||
text: string
|
||||
answers: { text: string; is_correct: boolean }[]
|
||||
}
|
||||
|
||||
const llmApi = {
|
||||
check: () =>
|
||||
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
|
||||
|
||||
generate: (topic: string, count = 7) =>
|
||||
axios
|
||||
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', { topic, count })
|
||||
.then((r) => r.data),
|
||||
|
||||
improve: (question: string, answers: string[]) =>
|
||||
axios
|
||||
.post<{ improved_question: string; improved_answers: string[] }>('/api/llm/improve', {
|
||||
question,
|
||||
answers,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
distractors: (question: string, answers: string[]) =>
|
||||
axios
|
||||
.post<{ distractors: string[] }>('/api/llm/distractors', { question, answers })
|
||||
.then((r) => r.data),
|
||||
|
||||
review: (title: string, questions: object[]) =>
|
||||
axios
|
||||
.post<{ review: string }>('/api/llm/review', { title, questions })
|
||||
.then((r) => r.data),
|
||||
|
||||
improveAll: (title: string, questions: object[]) =>
|
||||
axios
|
||||
.post<{ questions: { question: string; answers: string[] }[] }>('/api/llm/improve_all', {
|
||||
title,
|
||||
questions,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
export default llmApi
|
||||
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface Setting {
|
||||
key: string
|
||||
value: string | null
|
||||
}
|
||||
|
||||
const settingsApi = {
|
||||
get: (key: string) => axios.get<Setting>(`/api/settings/${key}`).then((r) => r.data),
|
||||
update: (key: string, value: string | null) =>
|
||||
axios.put<Setting>(`/api/settings/${key}`, { value }).then((r) => r.data),
|
||||
}
|
||||
|
||||
export default settingsApi
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ArrowLeftOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,13 +12,17 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
notification,
|
||||
} from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import llmApi, { LLMQuestion } from '../../api/llm'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const EMPTY_ANSWER = { text: '', is_correct: false }
|
||||
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }
|
||||
@@ -49,23 +59,247 @@ export default function TestForm({
|
||||
backLabel = 'Назад',
|
||||
}: TestFormProps) {
|
||||
const [form] = Form.useForm<TestFormValues>()
|
||||
const [notifApi, contextHolder] = notification.useNotification()
|
||||
|
||||
// Generate state
|
||||
const [generateLoading, setGenerateLoading] = useState(false)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
|
||||
|
||||
// Improve modal state
|
||||
const [improveState, setImproveState] = useState<{
|
||||
open: boolean
|
||||
qIndex: number | null
|
||||
loading: boolean
|
||||
original: { text: string; answers: { text: string; is_correct: boolean }[] } | null
|
||||
improved: { question: string; answers: string[] } | null
|
||||
applyQuestion: boolean
|
||||
applyAnswers: boolean[]
|
||||
}>({
|
||||
open: false,
|
||||
qIndex: null,
|
||||
loading: false,
|
||||
original: null,
|
||||
improved: null,
|
||||
applyQuestion: true,
|
||||
applyAnswers: [],
|
||||
})
|
||||
|
||||
// Distractors state: qIndex → loading
|
||||
const [distractorsLoading, setDistractorsLoading] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Review modal state
|
||||
const [reviewOpen, setReviewOpen] = useState(false)
|
||||
const [reviewLoading, setReviewLoading] = useState(false)
|
||||
const [reviewText, setReviewText] = useState('')
|
||||
// improve_all state (inside review modal)
|
||||
const [improveAllLoading, setImproveAllLoading] = useState(false)
|
||||
const [improveAllData, setImproveAllData] = useState<{
|
||||
original: { text: string; answers: { text: string; is_correct: boolean }[] }[]
|
||||
improved: { question: string; answers: string[] }[]
|
||||
applyQuestions: boolean[]
|
||||
applyAnswers: boolean[][]
|
||||
} | null>(null)
|
||||
|
||||
const defaultValues: Partial<TestFormValues> = {
|
||||
allow_navigation_back: true,
|
||||
has_timer: false,
|
||||
passing_score: 70,
|
||||
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })),
|
||||
questions: Array(7)
|
||||
.fill(null)
|
||||
.map(() => ({ ...EMPTY_QUESTION })),
|
||||
...initialValues,
|
||||
}
|
||||
|
||||
// ── AI: Генерация вопросов ──────────────────────────────────────────────
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = form.getFieldValue('title') as string
|
||||
if (!title?.trim()) return
|
||||
setGenerateLoading(true)
|
||||
setPreviewQuestions(null)
|
||||
setPreviewOpen(true)
|
||||
try {
|
||||
const data = await llmApi.generate(title.trim(), 7)
|
||||
setPreviewQuestions(data.questions)
|
||||
} catch {
|
||||
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
|
||||
setPreviewOpen(false)
|
||||
} finally {
|
||||
setGenerateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateApply = () => {
|
||||
if (!previewQuestions) return
|
||||
form.setFieldValue('questions', previewQuestions)
|
||||
setPreviewOpen(false)
|
||||
setPreviewQuestions(null)
|
||||
notifApi.success({ message: `Добавлено ${previewQuestions.length} вопросов` })
|
||||
}
|
||||
|
||||
const handleGenerateClose = () => {
|
||||
setPreviewOpen(false)
|
||||
setPreviewQuestions(null)
|
||||
}
|
||||
|
||||
// ── AI: Улучшить формулировку ──────────────────────────────────────────
|
||||
|
||||
const handleImprove = async (qIndex: number) => {
|
||||
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
|
||||
if (!questionText.trim()) {
|
||||
notifApi.warning({ message: 'Введите текст вопроса перед улучшением' })
|
||||
return
|
||||
}
|
||||
const answers: { text: string; is_correct: boolean }[] =
|
||||
form.getFieldValue(['questions', qIndex, 'answers']) || []
|
||||
|
||||
setImproveState({
|
||||
open: true,
|
||||
qIndex,
|
||||
loading: true,
|
||||
original: { text: questionText, answers },
|
||||
improved: null,
|
||||
applyQuestion: true,
|
||||
applyAnswers: answers.map(() => true),
|
||||
})
|
||||
|
||||
try {
|
||||
const answerTexts = answers.map((a) => a.text)
|
||||
const data = await llmApi.improve(questionText, answerTexts)
|
||||
setImproveState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
improved: { question: data.improved_question, answers: data.improved_answers },
|
||||
applyAnswers: data.improved_answers.map(() => true),
|
||||
}))
|
||||
} catch {
|
||||
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось улучшить вопрос' })
|
||||
setImproveState((prev) => ({ ...prev, open: false, loading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleImproveApply = () => {
|
||||
const { qIndex, original, improved, applyQuestion, applyAnswers } = improveState
|
||||
if (qIndex === null || !original || !improved) return
|
||||
|
||||
if (applyQuestion) {
|
||||
form.setFieldValue(['questions', qIndex, 'text'], improved.question)
|
||||
}
|
||||
|
||||
const updatedAnswers = original.answers.map((answer, i) => ({
|
||||
...answer,
|
||||
text: applyAnswers[i] && improved.answers[i] ? improved.answers[i] : answer.text,
|
||||
}))
|
||||
form.setFieldValue(['questions', qIndex, 'answers'], updatedAnswers)
|
||||
|
||||
setImproveState((prev) => ({ ...prev, open: false }))
|
||||
notifApi.success({ message: 'Изменения применены' })
|
||||
}
|
||||
|
||||
// ── AI: Добавить дистракторы ────────────────────────────────────────────
|
||||
|
||||
const handleDistractors = async (qIndex: number) => {
|
||||
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
|
||||
if (!questionText.trim()) {
|
||||
notifApi.warning({ message: 'Введите текст вопроса перед генерацией дистракторов' })
|
||||
return
|
||||
}
|
||||
const currentAnswers: { text: string; is_correct: boolean }[] =
|
||||
form.getFieldValue(['questions', qIndex, 'answers']) || []
|
||||
const answerTexts = currentAnswers.map((a) => a.text).filter(Boolean)
|
||||
|
||||
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: true }))
|
||||
try {
|
||||
const data = await llmApi.distractors(questionText, answerTexts)
|
||||
const newAnswers = [
|
||||
...currentAnswers,
|
||||
...data.distractors.map((d) => ({ text: d, is_correct: false })),
|
||||
]
|
||||
form.setFieldValue(['questions', qIndex, 'answers'], newAnswers)
|
||||
notifApi.success({ message: `Добавлено ${data.distractors.length} дистракторов` })
|
||||
} catch {
|
||||
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать дистракторы' })
|
||||
} finally {
|
||||
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI: Проверить тест ─────────────────────────────────────────────────
|
||||
|
||||
const handleReview = async () => {
|
||||
const values = form.getFieldsValue()
|
||||
if (!values.title) {
|
||||
notifApi.warning({ message: 'Введите название теста перед проверкой' })
|
||||
return
|
||||
}
|
||||
setReviewLoading(true)
|
||||
setReviewOpen(true)
|
||||
setReviewText('')
|
||||
setImproveAllData(null)
|
||||
try {
|
||||
const data = await llmApi.review(values.title, values.questions || [])
|
||||
setReviewText(data.review)
|
||||
} catch {
|
||||
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
|
||||
} finally {
|
||||
setReviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImproveAll = async () => {
|
||||
const values = form.getFieldsValue()
|
||||
const original: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
|
||||
(values.questions || []).map((q: { text: string; answers: { text: string; is_correct: boolean }[] }) => ({
|
||||
text: q.text,
|
||||
answers: q.answers || [],
|
||||
}))
|
||||
setImproveAllLoading(true)
|
||||
try {
|
||||
const data = await llmApi.improveAll(values.title, values.questions || [])
|
||||
setImproveAllData({
|
||||
original,
|
||||
improved: data.questions,
|
||||
applyQuestions: data.questions.map(() => true),
|
||||
applyAnswers: data.questions.map((q) => q.answers.map(() => true)),
|
||||
})
|
||||
} catch {
|
||||
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать улучшения' })
|
||||
} finally {
|
||||
setImproveAllLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImproveAllApply = () => {
|
||||
if (!improveAllData) return
|
||||
const questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
|
||||
form.getFieldValue('questions') || []
|
||||
const updated = questions.map((q, qi) => ({
|
||||
...q,
|
||||
text:
|
||||
improveAllData.applyQuestions[qi] && improveAllData.improved[qi]
|
||||
? improveAllData.improved[qi].question
|
||||
: q.text,
|
||||
answers: q.answers.map((a, ai) => ({
|
||||
...a,
|
||||
text:
|
||||
improveAllData.applyAnswers[qi]?.[ai] && improveAllData.improved[qi]?.answers[ai]
|
||||
? improveAllData.improved[qi].answers[ai]
|
||||
: a.text,
|
||||
})),
|
||||
}))
|
||||
form.setFieldValue('questions', updated)
|
||||
setReviewOpen(false)
|
||||
setImproveAllData(null)
|
||||
notifApi.success({ message: 'Изменения применены' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
|
||||
{contextHolder}
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={onBack} style={{ marginBottom: 16 }}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
)}
|
||||
@@ -129,6 +363,31 @@ export default function TestForm({
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* ── AI: кнопки ── */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) => prev.title !== cur.title}
|
||||
>
|
||||
{({ getFieldValue }) => (
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={handleGenerate}
|
||||
loading={generateLoading}
|
||||
disabled={!getFieldValue('title')?.trim()}
|
||||
>
|
||||
Сгенерировать с AI
|
||||
</Button>
|
||||
<Button
|
||||
icon={<StarOutlined />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
Проверить тест
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* ── Вопросы ── */}
|
||||
<Form.List
|
||||
name="questions"
|
||||
@@ -149,12 +408,32 @@ export default function TestForm({
|
||||
key={key}
|
||||
title={`Вопрос ${index + 1}`}
|
||||
extra={
|
||||
questionFields.length > 7 ? (
|
||||
<MinusCircleOutlined
|
||||
style={{ color: '#ff4d4f', fontSize: 16 }}
|
||||
onClick={() => removeQuestion(qName)}
|
||||
/>
|
||||
) : null
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RobotOutlined />}
|
||||
loading={improveState.loading && improveState.qIndex === index}
|
||||
onClick={() => handleImprove(index)}
|
||||
title="Улучшить формулировку"
|
||||
>
|
||||
Улучшить
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
loading={distractorsLoading[index]}
|
||||
onClick={() => handleDistractors(index)}
|
||||
title="Добавить дистракторы"
|
||||
>
|
||||
Дистракторы
|
||||
</Button>
|
||||
{questionFields.length > 7 && (
|
||||
<MinusCircleOutlined
|
||||
style={{ color: '#ff4d4f', fontSize: 16 }}
|
||||
onClick={() => removeQuestion(qName)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
@@ -263,6 +542,327 @@ export default function TestForm({
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* ── Modal: Превью сгенерированных вопросов ── */}
|
||||
<Modal
|
||||
title={<><RobotOutlined /> Сгенерированные вопросы</>}
|
||||
open={previewOpen}
|
||||
onCancel={handleGenerateClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{generateLoading && (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
|
||||
<div style={{ marginTop: 12, color: '#666' }}>AI генерирует вопросы...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!generateLoading && previewQuestions && (
|
||||
<>
|
||||
<Text type="secondary">
|
||||
Сгенерировано {previewQuestions.length} вопросов. Нажмите «Применить», чтобы
|
||||
заменить вопросы в форме.
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 340,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginTop: 12,
|
||||
marginBottom: 16,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{previewQuestions.map((q, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<Text strong>
|
||||
{i + 1}. {q.text}
|
||||
</Text>
|
||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||
{q.answers.map((a, ai) => (
|
||||
<li key={ai} style={{ color: a.is_correct ? '#52c41a' : undefined }}>
|
||||
{a.text} {a.is_correct && '✓'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleGenerateApply}>
|
||||
Применить все вопросы
|
||||
</Button>
|
||||
<Button onClick={handleGenerate}>Сгенерировать заново</Button>
|
||||
<Button onClick={handleGenerateClose}>Отмена</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal: Улучшение вопроса ── */}
|
||||
<Modal
|
||||
title={
|
||||
<>
|
||||
<RobotOutlined /> Улучшение вопроса{' '}
|
||||
{improveState.qIndex !== null ? improveState.qIndex + 1 : ''}
|
||||
</>
|
||||
}
|
||||
open={improveState.open}
|
||||
onCancel={() => setImproveState((prev) => ({ ...prev, open: false }))}
|
||||
width={680}
|
||||
footer={
|
||||
improveState.improved ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleImproveApply}>
|
||||
Применить выбранные
|
||||
</Button>
|
||||
<Button onClick={() => setImproveState((prev) => ({ ...prev, open: false }))}>
|
||||
Отмена
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{improveState.loading && (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
|
||||
<div style={{ marginTop: 12, color: '#666' }}>AI улучшает формулировки...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!improveState.loading && improveState.improved && improveState.original && (
|
||||
<>
|
||||
{/* Вопрос */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>Текст вопроса</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Текущая
|
||||
</Text>
|
||||
<div>{improveState.original.text}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
AI предлагает
|
||||
</Text>
|
||||
<div style={{ color: '#1677ff' }}>{improveState.improved.question}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={improveState.applyQuestion}
|
||||
onChange={(e) =>
|
||||
setImproveState((prev) => ({ ...prev, applyQuestion: e.target.checked }))
|
||||
}
|
||||
>
|
||||
Применить
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ответы */}
|
||||
<Text strong>Варианты ответов</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{improveState.original.answers.map((answer, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
padding: 12,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Вариант {i + 1}{answer.is_correct ? ' (правильный ✓)' : ''}
|
||||
</Text>
|
||||
<div>{answer.text}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
AI предлагает
|
||||
</Text>
|
||||
<div style={{ color: '#1677ff' }}>
|
||||
{improveState.improved.answers[i] ?? answer.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={improveState.applyAnswers[i]}
|
||||
onChange={(e) => {
|
||||
const next = [...improveState.applyAnswers]
|
||||
next[i] = e.target.checked
|
||||
setImproveState((prev) => ({ ...prev, applyAnswers: next }))
|
||||
}}
|
||||
>
|
||||
Применить
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal: Проверка теста + улучшение всего теста ── */}
|
||||
<Modal
|
||||
title={
|
||||
improveAllData
|
||||
? <><RobotOutlined /> Предложения по улучшению теста</>
|
||||
: <><StarOutlined /> Рекомендации AI</>
|
||||
}
|
||||
open={reviewOpen}
|
||||
onCancel={() => { setReviewOpen(false); setImproveAllData(null) }}
|
||||
width={700}
|
||||
footer={
|
||||
improveAllData ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleImproveAllApply}>
|
||||
Применить выбранные
|
||||
</Button>
|
||||
<Button onClick={() => setImproveAllData(null)}>
|
||||
← К рекомендациям
|
||||
</Button>
|
||||
<Button onClick={() => { setReviewOpen(false); setImproveAllData(null) }}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
{!reviewLoading && reviewText && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RobotOutlined />}
|
||||
loading={improveAllLoading}
|
||||
onClick={handleImproveAll}
|
||||
>
|
||||
Предложить вариант
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setReviewOpen(false)}>Закрыть</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Режим 1: рекомендации */}
|
||||
{!improveAllData && (
|
||||
reviewLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
|
||||
<div style={{ marginTop: 12, color: '#666' }}>AI анализирует тест...</div>
|
||||
</div>
|
||||
) : improveAllLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
|
||||
<div style={{ marginTop: 12, color: '#666' }}>AI готовит улучшенный вариант...</div>
|
||||
</div>
|
||||
) : (
|
||||
<Paragraph style={{ whiteSpace: 'pre-wrap' }}>{reviewText}</Paragraph>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Режим 2: сравнение старого и нового */}
|
||||
{improveAllData && (
|
||||
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
|
||||
{improveAllData.original.map((origQ, qi) => (
|
||||
<div key={qi} style={{ marginBottom: 20 }}>
|
||||
{/* Заголовок вопроса */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
background: '#f0f5ff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d6e4ff',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Вопрос {qi + 1} — текущий
|
||||
</Text>
|
||||
<div style={{ marginBottom: 4 }}>{origQ.text}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
AI предлагает
|
||||
</Text>
|
||||
<div style={{ color: '#1677ff' }}>
|
||||
{improveAllData.improved[qi]?.question ?? origQ.text}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={improveAllData.applyQuestions[qi]}
|
||||
onChange={(e) => {
|
||||
const next = [...improveAllData.applyQuestions]
|
||||
next[qi] = e.target.checked
|
||||
setImproveAllData((prev) => prev && { ...prev, applyQuestions: next })
|
||||
}}
|
||||
>
|
||||
Применить
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
{/* Ответы */}
|
||||
{origQ.answers.map((origA, ai) => (
|
||||
<div
|
||||
key={ai}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
background: '#fafafa',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f0f0f0',
|
||||
marginBottom: 4,
|
||||
marginLeft: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Вариант {ai + 1}{origA.is_correct ? ' (правильный ✓)' : ''}
|
||||
</Text>
|
||||
<div style={{ marginBottom: 2 }}>{origA.text}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>AI предлагает</Text>
|
||||
<div style={{ color: '#1677ff' }}>
|
||||
{improveAllData.improved[qi]?.answers[ai] ?? origA.text}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={improveAllData.applyAnswers[qi]?.[ai]}
|
||||
onChange={(e) => {
|
||||
const next = improveAllData.applyAnswers.map((row) => [...row])
|
||||
next[qi][ai] = e.target.checked
|
||||
setImproveAllData((prev) => prev && { ...prev, applyAnswers: next })
|
||||
}}
|
||||
>
|
||||
Применить
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Form, Input, Space, Spin, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import llmApi from '../../api/llm'
|
||||
import settingsApi from '../../api/settings'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const API_KEY = 'deepseek_api_key'
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [checkResult, setCheckResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
|
||||
const { data: setting, isLoading } = useQuery({
|
||||
queryKey: ['settings', API_KEY],
|
||||
queryFn: () => settingsApi.get(API_KEY),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (value: string) => settingsApi.update(API_KEY, value || null),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', API_KEY] })
|
||||
setCheckResult(null)
|
||||
},
|
||||
})
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => llmApi.check(),
|
||||
onSuccess: (data) => setCheckResult(data),
|
||||
})
|
||||
|
||||
const handleSave = (values: { api_key: string }) => {
|
||||
saveMutation.mutate(values.api_key)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 24px' }}>
|
||||
<Title level={2}>Настройки</Title>
|
||||
|
||||
<Card title="AI-помощник (DeepSeek)">
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
Введите API ключ DeepSeek для активации AI-функций при создании и редактировании
|
||||
тестов. Ключ хранится только на сервере.
|
||||
</Text>
|
||||
|
||||
<Form layout="vertical" onFinish={handleSave} initialValues={{ api_key: setting?.value ?? '' }}>
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label="API ключ DeepSeek"
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="sk-..."
|
||||
visibilityToggle
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
loading={checkMutation.isPending}
|
||||
disabled={!setting?.value}
|
||||
>
|
||||
Проверить подключение
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/')}>
|
||||
На главную
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{saveMutation.isSuccess && (
|
||||
<Alert
|
||||
type="success"
|
||||
message="Ключ сохранён"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
|
||||
{checkResult && (
|
||||
<Alert
|
||||
type={checkResult.ok ? 'success' : 'error'}
|
||||
message={checkResult.message}
|
||||
icon={
|
||||
checkResult.ok ? (
|
||||
<CheckCircleOutlined />
|
||||
) : (
|
||||
<CloseCircleOutlined />
|
||||
)
|
||||
}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
closable
|
||||
onClose={() => setCheckResult(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user