Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cef1172fc1 | |||
| 576fed06ac |
@@ -0,0 +1,91 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const ts = require('typescript');
|
||||||
|
const React = require('react');
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('SettingsPage.tsx', fs.readFileSync(require.resolve('../src/pages/settings/SettingsPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
|
const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL'].includes(node.declarationList.declarations[0].name.getText(source)));
|
||||||
|
function compile(code, globals = {}) {
|
||||||
|
const exports = {};
|
||||||
|
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals });
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n');
|
||||||
|
const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs);
|
||||||
|
function find(node, predicate) {
|
||||||
|
if (predicate(node)) return node;
|
||||||
|
return ts.forEachChild(node, child => find(child, predicate));
|
||||||
|
}
|
||||||
|
const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자'));
|
||||||
|
function controls(provider, model, custom = false) {
|
||||||
|
const state = { aiProvider: provider, aiModel: model, aiCustomModel: custom };
|
||||||
|
const context = {
|
||||||
|
aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null,
|
||||||
|
AI_PROVIDER_LABELS: { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' },
|
||||||
|
DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null,
|
||||||
|
setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; },
|
||||||
|
setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {},
|
||||||
|
};
|
||||||
|
const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree;
|
||||||
|
return { state, render };
|
||||||
|
}
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of Object.keys(AI_MODEL_OPTIONS)) {
|
||||||
|
test(`${provider} offers current models and a custom model field without changing defaults`, () => {
|
||||||
|
const h = controls(provider, AI_MODEL_DEFAULTS[provider]);
|
||||||
|
let tree = h.render();
|
||||||
|
const select = elements(tree, node => node.type === 'select')[0];
|
||||||
|
assert.equal(select.props.value, AI_MODEL_DEFAULTS[provider]);
|
||||||
|
assert.equal(elements(select, node => node.type === 'option' && node.props.value === CUSTOM_AI_MODEL).length, 1);
|
||||||
|
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text').length, 0);
|
||||||
|
select.props.onChange({ target: { value: CUSTOM_AI_MODEL } });
|
||||||
|
assert.equal(h.state.aiCustomModel, true);
|
||||||
|
assert.equal(h.state.aiModel, '');
|
||||||
|
tree = h.render();
|
||||||
|
const input = elements(tree, node => node.type === 'input' && node.props.type === 'text')[0];
|
||||||
|
assert.equal(input.props.maxLength, 120);
|
||||||
|
input.props.onChange({ target: { value: 'custom-model-2026' } });
|
||||||
|
assert.equal(h.state.aiModel, 'custom-model-2026');
|
||||||
|
assert.equal(elements(h.render(), node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
|
||||||
|
elements(h.render(), node => node.type === 'select')[0].props.onChange({ target: { value: AI_MODEL_OPTIONS[provider][0].value } });
|
||||||
|
assert.equal(h.state.aiCustomModel, false);
|
||||||
|
assert.equal(h.state.aiModel, AI_MODEL_OPTIONS[provider][0].value);
|
||||||
|
assert.equal(new Set(AI_MODEL_OPTIONS[provider].map(option => option.value)).size, AI_MODEL_OPTIONS[provider].length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('saved unknown model IDs remain visible in custom mode', () => {
|
||||||
|
const model = 'provider/private-model:revision-10';
|
||||||
|
const h = controls('QWEN', model, true);
|
||||||
|
const tree = h.render();
|
||||||
|
assert.equal(elements(tree, node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
|
||||||
|
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text')[0].props.value, model);
|
||||||
|
assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selected provider button preserves a custom draft', () => {
|
||||||
|
const h = controls('OPENAI', 'custom-gpt', true);
|
||||||
|
const providers = elements(h.render(), node => node.props.role === 'radio');
|
||||||
|
providers[0].props.onClick();
|
||||||
|
assert.equal(h.state.aiModel, 'custom-gpt');
|
||||||
|
assert.equal(h.state.aiCustomModel, true);
|
||||||
|
providers[1].props.onClick();
|
||||||
|
assert.equal(h.state.aiProvider, 'ANTHROPIC');
|
||||||
|
assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC);
|
||||||
|
assert.equal(h.state.aiCustomModel, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official current API IDs are present', () => {
|
||||||
|
for (const [provider, ids] of Object.entries({
|
||||||
|
OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'],
|
||||||
|
ANTHROPIC: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'],
|
||||||
|
GEMINI: ['gemini-4-argon', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'],
|
||||||
|
QWEN: ['qwen3.8-max', 'qwen3.8-flash'],
|
||||||
|
})) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const ts = require('typescript');
|
||||||
|
const React = require('react');
|
||||||
|
|
||||||
|
const read = path => fs.readFileSync(require.resolve(`../src/${path}`), 'utf8');
|
||||||
|
function compile(source, globals = {}) {
|
||||||
|
const exports = {};
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||||
|
vm.runInNewContext(code, { exports, require, ...globals });
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
|
||||||
|
}
|
||||||
|
const source = ts.createSourceFile('SettingsPage.tsx', read('pages/settings/SettingsPage.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
|
const page = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'SettingsPage');
|
||||||
|
const CourseAiSettings = () => null;
|
||||||
|
const { SettingsPage } = compile(page.getText(source), { CourseAiSettings, PageHeader: () => null, Bot: () => null });
|
||||||
|
const props = {
|
||||||
|
session: { accessToken: 'test-token', user: { id: 'teacher' } },
|
||||||
|
selectedCourseId: 'course-a',
|
||||||
|
courses: [{ id: 'course-a', title: 'A' }, { id: 'course-b', title: 'B' }],
|
||||||
|
onSelectCourse() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('settings remounts the credential draft when course or session changes', () => {
|
||||||
|
const form = input => elements(SettingsPage(input), node => node.type === CourseAiSettings)[0];
|
||||||
|
const initial = form(props);
|
||||||
|
assert.equal(initial.props.selectedCourseId, 'course-a');
|
||||||
|
assert.notEqual(initial.key, form({ ...props, selectedCourseId: 'course-b' }).key);
|
||||||
|
assert.notEqual(initial.key, form({ ...props, session: { ...props.session, accessToken: 'new-token' } }).key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings offers the global course selection and no credential form without a course', () => {
|
||||||
|
const selected = [];
|
||||||
|
const tree = SettingsPage({ ...props, onSelectCourse: id => selected.push(id) });
|
||||||
|
const header = React.Children.toArray(tree.props.children)[0];
|
||||||
|
const select = elements(header.props.action, node => node.type === 'select')[0];
|
||||||
|
assert.equal(select.props.value, 'course-a');
|
||||||
|
select.props.onChange({ target: { value: 'course-b' } });
|
||||||
|
assert.deepEqual(selected, ['course-b']);
|
||||||
|
assert.equal(elements(SettingsPage({ ...props, selectedCourseId: '', courses: [] }), node => node.type === CourseAiSettings).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curriculum no longer loads AI settings or offers an agent tab', () => {
|
||||||
|
const curriculum = read('pages/curriculum/CurriculumPage.tsx');
|
||||||
|
assert.doesNotMatch(curriculum, /getCourseAiSetting|updateCourseAiSetting|handleSaveAiSetting|setActiveTab\('ai'\)/);
|
||||||
|
assert.match(read('App.tsx'), /<SettingsPage session=\{session\} selectedCourseId=\{selectedCourseId\} courses=\{courses\} onSelectCourse=\{onSelectCourse\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analytics is hidden from the menu without deleting its existing route', () => {
|
||||||
|
const routes = compile(read('routes.ts').replace('import.meta.env.BASE_URL', JSON.stringify('/lms-adm/')));
|
||||||
|
assert.equal(routes.navItems.some(item => item.id === 'analytics'), false);
|
||||||
|
assert.equal(routes.navItems.some(item => item.id === 'settings'), true);
|
||||||
|
assert.equal(routes.isAppRoute('analytics'), true);
|
||||||
|
assert.equal(routes.getRouteFromPathname('/lms-adm/analytics'), 'analytics');
|
||||||
|
});
|
||||||
+4
-2
@@ -231,6 +231,8 @@ function App() {
|
|||||||
route={visibleRoute}
|
route={visibleRoute}
|
||||||
session={session}
|
session={session}
|
||||||
selectedCourseId={selectedCourseId}
|
selectedCourseId={selectedCourseId}
|
||||||
|
courses={courses}
|
||||||
|
onSelectCourse={handleSelectCourse}
|
||||||
onNavigate={setRoute}
|
onNavigate={setRoute}
|
||||||
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
|
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
|
||||||
/>
|
/>
|
||||||
@@ -294,7 +296,7 @@ function SessionExpiredPage({ onLogin }: { onLogin: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoutePage({ route, session, selectedCourseId, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) {
|
function RoutePage({ route, session, selectedCourseId, courses, onSelectCourse, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; courses: InstructorCourse[]; onSelectCourse: (courseId: string) => void; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) {
|
||||||
switch (route) {
|
switch (route) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />;
|
return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />;
|
||||||
@@ -311,7 +313,7 @@ function RoutePage({ route, session, selectedCourseId, onNavigate, onOpenChatThr
|
|||||||
case 'analytics':
|
case 'analytics':
|
||||||
return <AnalyticsPage />;
|
return <AnalyticsPage />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SettingsPage />;
|
return <SettingsPage session={session} selectedCourseId={selectedCourseId} courses={courses} onSelectCourse={onSelectCourse} />;
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return <ProfilePage session={session} />;
|
return <ProfilePage session={session} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ export const pageMeta: Record<AppRoute, PageMeta> = {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '설정',
|
title: '설정',
|
||||||
description: '강사 포털의 운영 정책과 계정 설정을 관리합니다.'
|
description: '강좌별 AI 에이전트 설정을 관리합니다.'
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: '프로필',
|
title: '프로필',
|
||||||
|
|||||||
@@ -1,56 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Bot, CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, KeyRound, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react';
|
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react';
|
||||||
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||||
import { WeekCoverEditor } from '../../components/WeekCoverEditor';
|
import { WeekCoverEditor } from '../../components/WeekCoverEditor';
|
||||||
import { PageHeader } from '../../components/PageHeader';
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
import { useToast } from '../../components/ToastProvider';
|
import { useToast } from '../../components/ToastProvider';
|
||||||
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getCourseAiSetting, getInstructorCurriculum, updateCourseAiSetting, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CourseAiSetting, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
|
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
|
||||||
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
||||||
import type { AuthSession } from '../../types/auth';
|
import type { AuthSession } from '../../types/auth';
|
||||||
|
|
||||||
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
|
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
|
||||||
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
|
|
||||||
OPENAI: 'gpt-4.1-mini',
|
|
||||||
ANTHROPIC: 'claude-sonnet-4-5',
|
|
||||||
GEMINI: 'gemini-2.5-flash',
|
|
||||||
QWEN: 'qwen3.7-plus',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
|
|
||||||
|
|
||||||
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
|
|
||||||
OPENAI: 'OpenAI',
|
|
||||||
ANTHROPIC: 'Claude',
|
|
||||||
GEMINI: 'Gemini',
|
|
||||||
QWEN: 'Qwen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
|
|
||||||
OPENAI: [
|
|
||||||
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
|
|
||||||
{ value: 'gpt-4.1', label: 'GPT-4.1' },
|
|
||||||
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
|
|
||||||
],
|
|
||||||
ANTHROPIC: [
|
|
||||||
{ value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
|
||||||
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
|
|
||||||
{ value: 'claude-opus-4-1', label: 'Claude Opus 4.1' },
|
|
||||||
],
|
|
||||||
GEMINI: [
|
|
||||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
|
||||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
|
||||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
|
||||||
],
|
|
||||||
QWEN: [
|
|
||||||
{ value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' },
|
|
||||||
{ value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' },
|
|
||||||
{ value: 'qwen-vl-plus', label: 'Qwen VL Plus' },
|
|
||||||
{ value: 'qwen-vl-max', label: 'Qwen VL Max' },
|
|
||||||
{ value: 'qwen-plus', label: 'Qwen Plus (text)' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModuleParameterDraft = {
|
type ModuleParameterDraft = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -185,15 +144,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const [referenceFile, setReferenceFile] = useState<File | null>(null);
|
const [referenceFile, setReferenceFile] = useState<File | null>(null);
|
||||||
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
|
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
|
||||||
const [exampleCodeFile, setExampleCodeFile] = useState<File | null>(null);
|
const [exampleCodeFile, setExampleCodeFile] = useState<File | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'maps' | 'examples' | 'ai'>('apply');
|
const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'maps' | 'examples'>('apply');
|
||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
|
||||||
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
|
|
||||||
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
|
|
||||||
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
|
||||||
const [aiApiKey, setAiApiKey] = useState('');
|
|
||||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
|
||||||
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
|
|
||||||
const [aiSettingLoading, setAiSettingLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
@@ -204,7 +155,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]);
|
const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]);
|
||||||
const hasGroups = groups.length > 0;
|
const hasGroups = groups.length > 0;
|
||||||
const selectedWeekCompleted = selectedWeek?.state === 'done';
|
const selectedWeekCompleted = selectedWeek?.state === 'done';
|
||||||
const primaryActionDisabled = saving || aiSettingLoading || (activeTab !== 'ai' && !selectedWeek) || (activeTab === 'apply' && !selectedGroup);
|
const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup);
|
||||||
const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? [];
|
const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? [];
|
||||||
const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? [];
|
const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? [];
|
||||||
const selectedWeekDeletable = selectedWeek?.canDelete === true;
|
const selectedWeekDeletable = selectedWeek?.canDelete === true;
|
||||||
@@ -319,57 +270,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
};
|
};
|
||||||
}, [session.accessToken, selectedGroupId, selectedCourseId]);
|
}, [session.accessToken, selectedGroupId, selectedCourseId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCourseId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setAiSettingLoading(true);
|
|
||||||
getCourseAiSetting(session.accessToken, selectedCourseId)
|
|
||||||
.then((setting) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setAiEnabled(setting.enabled);
|
|
||||||
setAiProvider(setting.provider);
|
|
||||||
setAiModel(setting.model);
|
|
||||||
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
|
||||||
setAiHasApiKey(setting.hasApiKey);
|
|
||||||
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
|
||||||
setAiApiKey('');
|
|
||||||
})
|
|
||||||
.catch((reason: Error) => {
|
|
||||||
if (!cancelled) setError(reason.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setAiSettingLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [selectedCourseId, session.accessToken]);
|
|
||||||
|
|
||||||
const handleSaveAiSetting = async () => {
|
|
||||||
if (!selectedCourseId) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
|
|
||||||
enabled: aiEnabled,
|
|
||||||
provider: aiProvider,
|
|
||||||
model: aiModel.trim(),
|
|
||||||
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
|
|
||||||
...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
|
|
||||||
});
|
|
||||||
setAiEnabled(setting.enabled);
|
|
||||||
setAiProvider(setting.provider);
|
|
||||||
setAiModel(setting.model);
|
|
||||||
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
|
||||||
setAiHasApiKey(setting.hasApiKey);
|
|
||||||
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
|
||||||
setAiApiKey('');
|
|
||||||
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' });
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedWeek) return;
|
if (!selectedWeek) return;
|
||||||
setDraftTagLabel(selectedWeek.tags[0] ?? '');
|
setDraftTagLabel(selectedWeek.tags[0] ?? '');
|
||||||
@@ -881,13 +781,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
>
|
>
|
||||||
예제 코드
|
예제 코드
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'ai' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
|
|
||||||
onClick={() => setActiveTab('ai')}
|
|
||||||
>
|
|
||||||
AI 에이전트
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="h-10 min-w-[190px] rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] shadow-sm outline-none transition hover:border-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:border-[#d1d9e6] disabled:bg-[#f8fafc] disabled:text-[#94a3b8]"
|
className="h-10 min-w-[190px] rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] shadow-sm outline-none transition hover:border-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:border-[#d1d9e6] disabled:bg-[#f8fafc] disabled:text-[#94a3b8]"
|
||||||
@@ -904,9 +797,9 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeTab !== 'examples' && !(activeTab === 'apply' && selectedWeekCompleted) ? (
|
{activeTab !== 'examples' && !(activeTab === 'apply' && selectedWeekCompleted) ? (
|
||||||
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void (activeTab === 'ai' ? handleSaveAiSetting() : activeTab === 'apply' ? handleApply() : activeTab === 'modules' || activeTab === 'maps' ? handleSaveModules() : handleSaveContent())} disabled={primaryActionDisabled}>
|
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void (activeTab === 'apply' ? handleApply() : activeTab === 'modules' || activeTab === 'maps' ? handleSaveModules() : handleSaveContent())} disabled={primaryActionDisabled}>
|
||||||
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : activeTab === 'apply' ? <CirclePlay size={15} aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
|
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : activeTab === 'apply' ? <CirclePlay size={15} aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
|
||||||
{activeTab === 'apply' ? '진행하기' : activeTab === 'modules' ? '모듈 저장' : activeTab === 'maps' ? '맵 저장' : activeTab === 'ai' ? 'AI 설정 저장' : '내용 저장'}
|
{activeTab === 'apply' ? '진행하기' : activeTab === 'modules' ? '모듈 저장' : activeTab === 'maps' ? '맵 저장' : '내용 저장'}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === 'apply' ? (
|
{activeTab === 'apply' ? (
|
||||||
@@ -1290,102 +1183,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
) : activeTab === 'ai' ? (
|
|
||||||
<div className="grid gap-5 p-5">
|
|
||||||
<section>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Bot size={17} className="text-[#2563eb]" aria-hidden="true" />
|
|
||||||
<h3 className="text-[15px] font-black text-[#111827]">강좌 AI 에이전트</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 max-w-[640px] text-[12px] font-semibold leading-6 text-[#64748b]">학생이 현재 모듈의 코드를 AI에게 분석시키고, 검토한 변경안을 코드 에디터에 적용할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
<label className="inline-flex cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
|
|
||||||
<input type="checkbox" checked={aiEnabled} onChange={(event) => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
|
|
||||||
사용
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-[#e2e8f0] pt-5">
|
|
||||||
<div>
|
|
||||||
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="AI 공급자">
|
|
||||||
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={aiProvider === provider}
|
|
||||||
disabled={aiSettingLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setAiProvider(provider);
|
|
||||||
setAiModel(AI_MODEL_DEFAULTS[provider]);
|
|
||||||
if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT);
|
|
||||||
setAiApiKey('');
|
|
||||||
}}
|
|
||||||
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
||||||
aiProvider === provider
|
|
||||||
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
|
|
||||||
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{AI_PROVIDER_LABELS[provider]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="mt-4 block">
|
|
||||||
<span className="text-[12px] font-black text-[#475569]">모델</span>
|
|
||||||
<select
|
|
||||||
value={aiModel}
|
|
||||||
disabled={aiSettingLoading}
|
|
||||||
onChange={(event) => setAiModel(event.target.value)}
|
|
||||||
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
|
||||||
>
|
|
||||||
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{aiProvider === 'QWEN' ? (
|
|
||||||
<label className="mt-4 block">
|
|
||||||
<span className="text-[12px] font-black text-[#475569]">엔드포인트 주소</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={aiEndpointUrl}
|
|
||||||
onChange={(event) => setAiEndpointUrl(event.target.value)}
|
|
||||||
placeholder={DEFAULT_QWEN_ENDPOINT}
|
|
||||||
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
|
||||||
/>
|
|
||||||
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">OpenAI 호환 API의 기본 주소를 입력하세요. HTTPS 주소만 사용할 수 있습니다.</span>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
<label className="mt-4 block">
|
|
||||||
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={aiApiKey}
|
|
||||||
onChange={(event) => setAiApiKey(event.target.value)}
|
|
||||||
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
|
|
||||||
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
|
||||||
/>
|
|
||||||
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-[#e2e8f0] pt-5">
|
|
||||||
<h3 className="text-[13px] font-black text-[#111827]">학생 적용 절차</h3>
|
|
||||||
<ol className="mt-3 grid gap-2 text-[12px] font-semibold leading-6 text-[#64748b]">
|
|
||||||
<li>1. 학생이 에이전트 탭에서 요청을 입력합니다.</li>
|
|
||||||
<li>2. AI가 현재 모듈 파일을 읽고 파일별 변경안을 만듭니다.</li>
|
|
||||||
<li>3. 학생이 변경 내용을 확인하고 적용 버튼을 누릅니다.</li>
|
|
||||||
<li>4. 변경 파일이 코드 에디터에 열리며 빌드와 실행은 학생이 직접 수행합니다.</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 p-5">
|
<div className="grid gap-4 p-5">
|
||||||
|
|||||||
@@ -1,6 +1,306 @@
|
|||||||
import { Settings } from 'lucide-react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { PlaceholderPage } from '../common/PlaceholderPage';
|
import { Bot, KeyRound, Loader2, RefreshCw, Save } from 'lucide-react';
|
||||||
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
|
import { useToast } from '../../components/ToastProvider';
|
||||||
|
import { getCourseAiSetting, updateCourseAiSetting, type CourseAiSetting } from '../../lib/curriculum';
|
||||||
|
import type { InstructorCourse } from '../../lib/courses';
|
||||||
|
import type { AuthSession } from '../../types/auth';
|
||||||
|
|
||||||
export function SettingsPage() {
|
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
|
||||||
return <PlaceholderPage icon={Settings} route="settings" items={['권한 정책', '알림 설정', '서비스 연결']} />;
|
OPENAI: 'gpt-4.1-mini',
|
||||||
|
ANTHROPIC: 'claude-sonnet-4-5',
|
||||||
|
GEMINI: 'gemini-2.5-flash',
|
||||||
|
QWEN: 'qwen3.7-plus',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
|
||||||
|
const CUSTOM_AI_MODEL = '__custom__';
|
||||||
|
|
||||||
|
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
|
||||||
|
OPENAI: 'OpenAI',
|
||||||
|
ANTHROPIC: 'Claude',
|
||||||
|
GEMINI: 'Gemini',
|
||||||
|
QWEN: 'Qwen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
|
||||||
|
// Official provider model catalogs checked on 2026-10-19; keep existing selections intact.
|
||||||
|
OPENAI: [
|
||||||
|
{ value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
|
||||||
|
{ value: 'gpt-6-astra', label: 'GPT-6 Astra' },
|
||||||
|
{ value: 'gpt-6-luna', label: 'GPT-6 Luna' },
|
||||||
|
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
|
||||||
|
{ value: 'gpt-4.1', label: 'GPT-4.1' },
|
||||||
|
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
|
||||||
|
],
|
||||||
|
ANTHROPIC: [
|
||||||
|
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
|
||||||
|
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
|
||||||
|
{ value: 'claude-haiku-5-5', label: 'Claude Haiku 5.5' },
|
||||||
|
{ value: 'claude-fable-5-1', label: 'Claude Fable 5.1' },
|
||||||
|
{ value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
|
||||||
|
{ value: 'claude-opus-4-1', label: 'Claude Opus 4.1' },
|
||||||
|
],
|
||||||
|
GEMINI: [
|
||||||
|
{ value: 'gemini-4-argon', label: 'Gemini 4 Argon' },
|
||||||
|
{ value: 'gemini-3.8-flash', label: 'Gemini 3.8 Flash' },
|
||||||
|
{ value: 'gemini-3.5-flash-lite', label: 'Gemini 3.5 Flash-Lite' },
|
||||||
|
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro (Preview)' },
|
||||||
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
|
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||||
|
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||||
|
],
|
||||||
|
QWEN: [
|
||||||
|
{ value: 'qwen3.8-max', label: 'Qwen 3.8 Max' },
|
||||||
|
{ value: 'qwen3.8-flash', label: 'Qwen 3.8 Flash' },
|
||||||
|
{ value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' },
|
||||||
|
{ value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' },
|
||||||
|
{ value: 'qwen-vl-plus', label: 'Qwen VL Plus' },
|
||||||
|
{ value: 'qwen-vl-max', label: 'Qwen VL Max' },
|
||||||
|
{ value: 'qwen-plus', label: 'Qwen Plus (text)' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsPageProps = {
|
||||||
|
session: AuthSession;
|
||||||
|
selectedCourseId: string;
|
||||||
|
courses: InstructorCourse[];
|
||||||
|
onSelectCourse: (courseId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsPage({ session, selectedCourseId, courses, onSelectCourse }: SettingsPageProps) {
|
||||||
|
const selectedCourse = courses.find(course => course.id === selectedCourseId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1220px]">
|
||||||
|
<PageHeader route="settings" action={
|
||||||
|
<label className="grid w-full gap-1.5 text-[12px] font-bold text-[#475569] sm:w-[280px]">
|
||||||
|
강좌
|
||||||
|
<select
|
||||||
|
aria-label="강좌"
|
||||||
|
className="h-10 w-full min-w-0 rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb] disabled:opacity-60"
|
||||||
|
value={selectedCourseId}
|
||||||
|
disabled={!courses.length}
|
||||||
|
onChange={event => onSelectCourse(event.target.value)}
|
||||||
|
>
|
||||||
|
{courses.length ? courses.map(course => <option key={course.id} value={course.id}>{course.title}</option>) : <option value="">등록된 강좌 없음</option>}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
} />
|
||||||
|
{selectedCourse ? (
|
||||||
|
<CourseAiSettings key={`${session.user.id}:${session.accessToken}:${selectedCourseId}`} session={session} selectedCourseId={selectedCourseId} />
|
||||||
|
) : (
|
||||||
|
<section className="border-y border-[#d1d9e6] bg-white px-5 py-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
|
||||||
|
<p className="mt-3 text-[13px] font-semibold text-[#64748b]">설정할 강좌가 없습니다.</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
|
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
|
||||||
|
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
|
||||||
|
const [aiCustomModel, setAiCustomModel] = useState(false);
|
||||||
|
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
||||||
|
const [aiApiKey, setAiApiKey] = useState('');
|
||||||
|
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
||||||
|
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
|
||||||
|
const [aiSettingLoading, setAiSettingLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const mounted = useRef(false);
|
||||||
|
|
||||||
|
const applySetting = (setting: CourseAiSetting) => {
|
||||||
|
setAiEnabled(setting.enabled);
|
||||||
|
setAiProvider(setting.provider);
|
||||||
|
setAiModel(setting.model);
|
||||||
|
setAiCustomModel(!AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
|
||||||
|
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
||||||
|
setAiHasApiKey(setting.hasApiKey);
|
||||||
|
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
||||||
|
setAiApiKey('');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => { mounted.current = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setAiSettingLoading(true);
|
||||||
|
setLoaded(false);
|
||||||
|
setError(null);
|
||||||
|
getCourseAiSetting(session.accessToken, selectedCourseId)
|
||||||
|
.then(setting => {
|
||||||
|
if (cancelled) return;
|
||||||
|
applySetting(setting);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((reason: Error) => {
|
||||||
|
if (!cancelled) setError(reason.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setAiSettingLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [session.accessToken, selectedCourseId, loadAttempt]);
|
||||||
|
|
||||||
|
const handleSaveAiSetting = async () => {
|
||||||
|
if (!loaded || aiSettingLoading || saving) return;
|
||||||
|
if (!aiModel.trim() || aiModel.trim().length > 120) {
|
||||||
|
setError('모델 ID는 1~120자로 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
|
||||||
|
enabled: aiEnabled,
|
||||||
|
provider: aiProvider,
|
||||||
|
model: aiModel.trim(),
|
||||||
|
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
|
||||||
|
...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
|
||||||
|
});
|
||||||
|
if (!mounted.current) return;
|
||||||
|
applySetting(setting);
|
||||||
|
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' });
|
||||||
|
} catch (reason) {
|
||||||
|
if (mounted.current) setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="border-y border-[#d1d9e6] bg-white"
|
||||||
|
onSubmit={event => { event.preventDefault(); void handleSaveAiSetting(); }}
|
||||||
|
>
|
||||||
|
<fieldset disabled={!loaded || aiSettingLoading || saving} className="min-w-0 px-5 py-5">
|
||||||
|
<legend className="sr-only">AI 에이전트</legend>
|
||||||
|
<div className="flex items-center justify-between gap-4 pb-5">
|
||||||
|
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
|
||||||
|
<label className="inline-flex shrink-0 cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
|
||||||
|
<input type="checkbox" aria-label="AI 에이전트 사용" checked={aiEnabled} onChange={event => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||||
|
사용
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{aiSettingLoading ? (
|
||||||
|
<p role="status" className="flex items-center gap-2 border-t border-[#e2e8f0] py-8 text-[13px] font-semibold text-[#64748b]"><Loader2 size={16} className="animate-spin" aria-hidden="true" />불러오는 중</p>
|
||||||
|
) : loaded ? (
|
||||||
|
<section className="border-t border-[#e2e8f0] pt-5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="AI 공급자">
|
||||||
|
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={aiProvider === provider}
|
||||||
|
disabled={aiSettingLoading || saving}
|
||||||
|
onClick={() => {
|
||||||
|
if (provider === aiProvider) return;
|
||||||
|
setAiProvider(provider);
|
||||||
|
setAiModel(AI_MODEL_DEFAULTS[provider]);
|
||||||
|
setAiCustomModel(false);
|
||||||
|
if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT);
|
||||||
|
setAiApiKey('');
|
||||||
|
}}
|
||||||
|
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
aiProvider === provider
|
||||||
|
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
|
||||||
|
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{AI_PROVIDER_LABELS[provider]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="text-[12px] font-black text-[#475569]">모델</span>
|
||||||
|
<select
|
||||||
|
aria-label="모델"
|
||||||
|
value={aiCustomModel ? CUSTOM_AI_MODEL : aiModel}
|
||||||
|
disabled={aiSettingLoading || saving}
|
||||||
|
onChange={(event) => {
|
||||||
|
const custom = event.target.value === CUSTOM_AI_MODEL;
|
||||||
|
setAiCustomModel(custom);
|
||||||
|
setAiModel(custom ? '' : event.target.value);
|
||||||
|
}}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||||
|
>
|
||||||
|
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
<option value={CUSTOM_AI_MODEL}>직접 입력</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{aiCustomModel ? (
|
||||||
|
<label className="mt-3 block">
|
||||||
|
<span className="text-[12px] font-black text-[#475569]">모델 ID</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={aiModel}
|
||||||
|
disabled={aiSettingLoading || saving}
|
||||||
|
onChange={(event) => setAiModel(event.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={AI_MODEL_OPTIONS[aiProvider][0].value}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{aiProvider === 'QWEN' ? (
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="text-[12px] font-black text-[#475569]">엔드포인트 주소</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={aiEndpointUrl}
|
||||||
|
onChange={(event) => setAiEndpointUrl(event.target.value)}
|
||||||
|
placeholder={DEFAULT_QWEN_ENDPOINT}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">OpenAI 호환 API의 기본 주소를 입력하세요. HTTPS 주소만 사용할 수 있습니다.</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={aiApiKey}
|
||||||
|
onChange={(event) => setAiApiKey(event.target.value)}
|
||||||
|
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" className="mx-5 mb-5 flex flex-wrap items-center justify-between gap-3 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-semibold text-[#be123c]">
|
||||||
|
<span className="min-w-0 break-words">{error}</span>
|
||||||
|
{!loaded ? <button type="button" onClick={() => setLoadAttempt(attempt => attempt + 1)} className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#fda4af] px-3"><RefreshCw size={14} aria-hidden="true" />다시 시도</button> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end border-t border-[#e2e8f0] px-5 py-4">
|
||||||
|
<button type="submit" disabled={!loaded || aiSettingLoading || saving || !aiModel.trim() || aiModel.trim().length > 120} className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
|
||||||
|
AI 설정 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,4 +1,4 @@
|
|||||||
import { Activity, BarChart3, BookOpen, Car, ClipboardList, GitPullRequest, Settings, Users, type LucideIcon } from 'lucide-react';
|
import { Activity, BookOpen, Car, ClipboardList, GitPullRequest, Settings, Users, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export type AppRoute = 'dashboard' | 'curriculum' | 'groups' | 'platforms' | 'students' | 'submissions' | 'analytics' | 'settings' | 'profile';
|
export type AppRoute = 'dashboard' | 'curriculum' | 'groups' | 'platforms' | 'students' | 'submissions' | 'analytics' | 'settings' | 'profile';
|
||||||
|
|
||||||
@@ -30,7 +30,6 @@ export const navItems: NavItem[] = [
|
|||||||
{ id: 'platforms', label: '플랫폼 관리', icon: Car },
|
{ id: 'platforms', label: '플랫폼 관리', icon: Car },
|
||||||
{ id: 'students', label: '수강생 현황', icon: BookOpen },
|
{ id: 'students', label: '수강생 현황', icon: BookOpen },
|
||||||
{ id: 'submissions', label: '제출 현황', icon: GitPullRequest },
|
{ id: 'submissions', label: '제출 현황', icon: GitPullRequest },
|
||||||
{ id: 'analytics', label: '통계 분석', icon: BarChart3 },
|
|
||||||
{ id: 'settings', label: '설정', icon: Settings }
|
{ id: 'settings', label: '설정', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ export const navItems: NavItem[] = [
|
|||||||
// settings: ['권한 정책', '알림 설정', '서비스 연결']
|
// settings: ['권한 정책', '알림 설정', '서비스 연결']
|
||||||
// };
|
// };
|
||||||
|
|
||||||
const appRoutes: AppRoute[] = [...navItems.map((item) => item.id), 'profile'];
|
const appRoutes = Object.keys(routePaths) as AppRoute[];
|
||||||
const publicBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '');
|
const publicBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '');
|
||||||
|
|
||||||
const stripPublicBasePath = (pathname: string): string => {
|
const stripPublicBasePath = (pathname: string): string => {
|
||||||
|
|||||||
Reference in New Issue
Block a user