2 Commits

6 changed files with 201 additions and 85 deletions
+84 -11
View File
@@ -6,27 +6,34 @@ const ts = require('typescript');
const React = require('react'); 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 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))); const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL', 'AI_PROVIDER_LABELS', 'DEFAULT_QWEN_ENDPOINT'].includes(node.declarationList.declarations[0].name.getText(source)));
function compile(code, globals = {}) { function compile(code, globals = {}) {
const exports = {}; const exports = {};
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals }); vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals });
return exports; return exports;
} }
const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n'); const helpers = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['aiSettingDraft', 'aiSettingDrafts'].includes(node.name?.text));
const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs); const catalogs = [...constants, ...helpers].map(node => `export ${node.getText(source)}`).join('\n');
const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL, aiSettingDrafts } = compile(catalogs);
function find(node, predicate) { function find(node, predicate) {
if (predicate(node)) return node; if (predicate(node)) return node;
return ts.forEachChild(node, child => find(child, predicate)); 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 공급자')); 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) { function controls(provider, model, custom = false, configured = true) {
const state = { aiProvider: provider, aiModel: model, aiCustomModel: custom }; const state = { aiProvider: provider, drafts: aiSettingDrafts() };
Object.assign(state.drafts[provider], { model, customModel: custom });
for (const [name, field] of [['aiModel', 'model'], ['aiCustomModel', 'customModel'], ['aiApiKey', 'apiKey'], ['aiEndpointUrl', 'endpointUrl']]) {
Object.defineProperty(state, name, { enumerable: true, get: () => state.drafts[state.aiProvider][field] });
}
const context = { const context = {
aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null, aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null,
AI_PROVIDER_LABELS: { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' }, aiCopilotConfigured: configured,
DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null, DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null,
setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; }, setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.drafts[state.aiProvider].model = value; },
setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {}, setAiCustomModel(value) { state.drafts[state.aiProvider].customModel = value; },
setAiApiKey(value) { state.drafts[state.aiProvider].apiKey = value; },
setAiEndpointUrl(value) { state.drafts[state.aiProvider].endpointUrl = value; }, setError() {},
}; };
const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree; const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree;
return { state, render }; return { state, render };
@@ -37,7 +44,7 @@ function elements(root, predicate) {
} }
for (const provider of Object.keys(AI_MODEL_OPTIONS)) { for (const provider of Object.keys(AI_MODEL_OPTIONS)) {
test(`${provider} offers current models and a custom model field without changing defaults`, () => { test(`${provider} offers current models and a custom model field`, () => {
const h = controls(provider, AI_MODEL_DEFAULTS[provider]); const h = controls(provider, AI_MODEL_DEFAULTS[provider]);
let tree = h.render(); let tree = h.render();
const select = elements(tree, node => node.type === 'select')[0]; const select = elements(tree, node => node.type === 'select')[0];
@@ -60,6 +67,17 @@ for (const provider of Object.keys(AI_MODEL_OPTIONS)) {
}); });
} }
test('provider defaults select the highest-tier current models at the top of each list', () => {
const expected = { OPENAI: 'gpt-6-astra', ANTHROPIC: 'claude-fable-5-1', GEMINI: 'gemini-4-argon', QWEN: 'qwen3.8-max' };
for (const [provider, model] of Object.entries(expected)) {
assert.equal(AI_MODEL_DEFAULTS[provider], model);
assert.equal(AI_MODEL_OPTIONS[provider][0].value, model);
const h = controls('A_COPILOT', 'a-copilot');
elements(h.render(), node => node.props.role === 'radio' && node.props['aria-label'] === ({ OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' })[provider])[0].props.onClick();
assert.equal(h.state.aiModel, model);
}
});
test('saved unknown model IDs remain visible in custom mode', () => { test('saved unknown model IDs remain visible in custom mode', () => {
const model = 'provider/private-model:revision-10'; const model = 'provider/private-model:revision-10';
const h = controls('QWEN', model, true); const h = controls('QWEN', model, true);
@@ -69,18 +87,45 @@ test('saved unknown model IDs remain visible in custom mode', () => {
assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false); assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false);
}); });
test('Qwen Max 0902 is selectable and saved selections restore without custom mode', () => {
const model = 'qwen3.8-max-0902';
const h = controls('QWEN', AI_MODEL_DEFAULTS.QWEN);
const select = elements(h.render(), node => node.type === 'select')[0];
const option = elements(select, node => node.type === 'option' && node.props.value === model);
assert.equal(option.length, 1);
assert.equal(option[0].props.children, 'Qwen 3.8 Max (0902)');
select.props.onChange({ target: { value: model } });
assert.equal(h.state.aiModel, model);
assert.equal(h.state.aiCustomModel, false);
const restored = aiSettingDrafts([{ provider: 'QWEN', enabled: true, model, hasApiKey: true }]);
assert.equal(restored.QWEN.model, model);
assert.equal(restored.QWEN.customModel, false);
assert.equal(AI_MODEL_DEFAULTS.QWEN, 'qwen3.8-max');
});
test('the selected provider button preserves a custom draft', () => { test('the selected provider button preserves a custom draft', () => {
const h = controls('OPENAI', 'custom-gpt', true); const h = controls('OPENAI', 'custom-gpt', true);
const providers = elements(h.render(), node => node.props.role === 'radio'); const providers = elements(h.render(), node => node.props.role === 'radio');
providers[0].props.onClick(); providers.find(node => node.props['aria-label'] === 'OpenAI').props.onClick();
assert.equal(h.state.aiModel, 'custom-gpt'); assert.equal(h.state.aiModel, 'custom-gpt');
assert.equal(h.state.aiCustomModel, true); assert.equal(h.state.aiCustomModel, true);
providers[1].props.onClick(); providers.find(node => node.props['aria-label'] === 'Claude').props.onClick();
assert.equal(h.state.aiProvider, 'ANTHROPIC'); assert.equal(h.state.aiProvider, 'ANTHROPIC');
assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC); assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC);
assert.equal(h.state.aiCustomModel, false); assert.equal(h.state.aiCustomModel, false);
}); });
test('A-Copilot is first and exposes only fixed read-only fields without a model picker', () => {
const tree = controls('A_COPILOT', 'a-copilot').render();
assert.equal(elements(tree, node => node.props.role === 'radio')[0].props['aria-label'], 'A-Copilot');
assert.equal(elements(tree, node => node.type === 'select').length, 0);
const inputs = elements(tree, node => node.type === 'input');
assert.deepEqual(inputs.map(node => node.props.value), ['A-Copilot', '서버에서 관리']);
assert.ok(inputs.every(node => node.props.readOnly && !node.props.onChange));
assert.equal(AI_MODEL_DEFAULTS.A_COPILOT, 'a-copilot');
assert.equal(elements(controls('A_COPILOT', 'a-copilot', false, false).render(), node => node.props.role === 'alert').length, 1);
});
test('official current API IDs are present', () => { test('official current API IDs are present', () => {
for (const [provider, ids] of Object.entries({ for (const [provider, ids] of Object.entries({
OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'], OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'],
@@ -89,3 +134,31 @@ test('official current API IDs are present', () => {
QWEN: ['qwen3.8-max', 'qwen3.8-flash'], QWEN: ['qwen3.8-max', 'qwen3.8-flash'],
})) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id); })) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id);
}); });
test('all new providers default to disabled while saved settings stay intact', () => {
assert.ok(Object.values(aiSettingDrafts()).every(draft => !draft.enabled && !draft.hasApiKey && draft.apiKey === ''));
const drafts = aiSettingDrafts([{ provider: 'QWEN', enabled: true, model: 'custom-qwen', hasApiKey: true, endpointUrl: 'https://qwen.test/v1' }]);
assert.equal(drafts.QWEN.enabled, true);
assert.equal(drafts.QWEN.model, 'custom-qwen');
assert.equal(drafts.QWEN.customModel, true);
assert.equal(drafts.OPENAI.enabled, false);
assert.equal(drafts.OPENAI.hasApiKey, false);
});
test('switching providers preserves separate unsaved models, keys and availability', () => {
const h = controls('OPENAI', 'private-model', true);
Object.assign(h.state.drafts.OPENAI, { enabled: true, apiKey: 'openai-draft' });
const provider = name => elements(h.render(), node => node.props.role === 'radio' && node.props['aria-label'] === name)[0];
provider('Qwen').props.onClick();
assert.equal(h.state.aiApiKey, '');
Object.assign(h.state.drafts.QWEN, { model: 'qwen3.8-flash', enabled: false, apiKey: 'qwen-draft' });
provider('OpenAI').props.onClick();
assert.equal(h.state.aiModel, 'private-model');
assert.equal(h.state.aiCustomModel, true);
assert.equal(h.state.aiApiKey, 'openai-draft');
assert.equal(h.state.drafts.OPENAI.enabled, true);
provider('Qwen').props.onClick();
assert.equal(h.state.aiModel, 'qwen3.8-flash');
assert.equal(h.state.aiApiKey, 'qwen-draft');
assert.equal(h.state.drafts.QWEN.enabled, false);
});
+12 -9
View File
@@ -24,7 +24,6 @@ const props = {
session: { accessToken: 'test-token', user: { id: 'teacher' } }, session: { accessToken: 'test-token', user: { id: 'teacher' } },
selectedCourseId: 'course-a', selectedCourseId: 'course-a',
courses: [{ id: 'course-a', title: 'A' }, { id: 'course-b', title: 'B' }], courses: [{ id: 'course-a', title: 'A' }, { id: 'course-b', title: 'B' }],
onSelectCourse() {},
}; };
test('settings remounts the credential draft when course or session changes', () => { test('settings remounts the credential draft when course or session changes', () => {
@@ -35,21 +34,20 @@ test('settings remounts the credential draft when course or session changes', ()
assert.notEqual(initial.key, form({ ...props, session: { ...props.session, accessToken: 'new-token' } }).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', () => { test('settings is width-constrained and centered without a duplicate course selector', () => {
const selected = []; const tree = SettingsPage(props);
const tree = SettingsPage({ ...props, onSelectCourse: id => selected.push(id) }); assert.match(tree.props.className, /mx-auto.*w-full.*max-w-\[920px\]/);
const header = React.Children.toArray(tree.props.children)[0]; const header = React.Children.toArray(tree.props.children)[0];
const select = elements(header.props.action, node => node.type === 'select')[0]; assert.equal(header.props.action, undefined);
assert.equal(select.props.value, 'course-a'); assert.equal(elements(tree, node => node.type === 'select').length, 0);
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); 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', () => { test('curriculum no longer loads AI settings or offers an agent tab', () => {
const curriculum = read('pages/curriculum/CurriculumPage.tsx'); const curriculum = read('pages/curriculum/CurriculumPage.tsx');
assert.doesNotMatch(curriculum, /getCourseAiSetting|updateCourseAiSetting|handleSaveAiSetting|setActiveTab\('ai'\)/); assert.doesNotMatch(curriculum, /getCourseAiSetting|updateCourseAiSetting|handleSaveAiSetting|setActiveTab\('ai'\)/);
assert.match(read('App.tsx'), /<SettingsPage session=\{session\} selectedCourseId=\{selectedCourseId\} courses=\{courses\} onSelectCourse=\{onSelectCourse\}/); assert.match(read('App.tsx'), /<SettingsPage session=\{session\} selectedCourseId=\{selectedCourseId\} courses=\{courses\} \/>/);
assert.match(read('App.tsx'), /<InstructorLayout[\s\S]*?onSelectCourse=\{handleSelectCourse\}/);
}); });
test('analytics is hidden from the menu without deleting its existing route', () => { test('analytics is hidden from the menu without deleting its existing route', () => {
@@ -59,3 +57,8 @@ test('analytics is hidden from the menu without deleting its existing route', ()
assert.equal(routes.isAppRoute('analytics'), true); assert.equal(routes.isAppRoute('analytics'), true);
assert.equal(routes.getRouteFromPathname('/lms-adm/analytics'), 'analytics'); assert.equal(routes.getRouteFromPathname('/lms-adm/analytics'), 'analytics');
}); });
test('settings has a general-purpose noun phrase rather than an AI-only sentence', () => {
const { pageMeta } = compile(read('pageMeta.ts'));
assert.equal(pageMeta.settings.description, '강좌 및 서비스 환경 설정');
});
+2 -3
View File
@@ -232,7 +232,6 @@ function App() {
session={session} session={session}
selectedCourseId={selectedCourseId} selectedCourseId={selectedCourseId}
courses={courses} courses={courses}
onSelectCourse={handleSelectCourse}
onNavigate={setRoute} onNavigate={setRoute}
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })} onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
/> />
@@ -296,7 +295,7 @@ function SessionExpiredPage({ onLogin }: { onLogin: () => 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 }) { function RoutePage({ route, session, selectedCourseId, courses, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; courses: InstructorCourse[]; 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} />;
@@ -313,7 +312,7 @@ function RoutePage({ route, session, selectedCourseId, courses, onSelectCourse,
case 'analytics': case 'analytics':
return <AnalyticsPage />; return <AnalyticsPage />;
case 'settings': case 'settings':
return <SettingsPage session={session} selectedCourseId={selectedCourseId} courses={courses} onSelectCourse={onSelectCourse} />; return <SettingsPage session={session} selectedCourseId={selectedCourseId} courses={courses} />;
case 'profile': case 'profile':
return <ProfilePage session={session} />; return <ProfilePage session={session} />;
} }
+7 -4
View File
@@ -115,13 +115,16 @@ export type InstructorCurriculumResponse = {
export type CourseAiSetting = { export type CourseAiSetting = {
courseId: string; courseId: string;
enabled: boolean; enabled: boolean;
provider: 'OPENAI' | 'ANTHROPIC' | 'GEMINI' | 'QWEN'; provider: 'A_COPILOT' | 'OPENAI' | 'ANTHROPIC' | 'GEMINI' | 'QWEN';
model: string; model: string;
endpointUrl?: string | null; endpointUrl?: string | null;
hasApiKey: boolean; hasApiKey: boolean;
copilotConfigured?: boolean;
updatedAt?: string | null; updatedAt?: string | null;
}; };
export type CourseAiSettings = CourseAiSetting & { settings: CourseAiSetting[] };
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, ''); const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
function uploadErrorMessage(status: number, fallback: string) { function uploadErrorMessage(status: number, fallback: string) {
@@ -144,18 +147,18 @@ export async function getInstructorCurriculum(accessToken: string, groupId?: str
return response.json() as Promise<InstructorCurriculumResponse>; return response.json() as Promise<InstructorCurriculumResponse>;
} }
export async function getCourseAiSetting(accessToken: string, courseId: string): Promise<CourseAiSetting> { export async function getCourseAiSetting(accessToken: string, courseId: string): Promise<CourseAiSettings> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, { const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });
if (!response.ok) throw new Error(`AI 에이전트 설정을 불러오지 못했습니다. (${response.status})`); if (!response.ok) throw new Error(`AI 에이전트 설정을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<CourseAiSetting>; return response.json() as Promise<CourseAiSettings>;
} }
export async function updateCourseAiSetting( export async function updateCourseAiSetting(
accessToken: string, accessToken: string,
courseId: string, courseId: string,
input: Pick<CourseAiSetting, 'enabled' | 'provider' | 'model'> & { apiKey?: string; endpointUrl?: string }, input: Pick<CourseAiSetting, 'enabled' | 'provider'> & { model?: string; apiKey?: string; endpointUrl?: string },
): Promise<CourseAiSetting> { ): Promise<CourseAiSetting> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, { const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, {
method: 'PUT', method: 'PUT',
+1 -1
View File
@@ -36,7 +36,7 @@ export const pageMeta: Record<AppRoute, PageMeta> = {
}, },
settings: { settings: {
title: '설정', title: '설정',
description: '강좌별 AI 에이전트 설정을 관리합니다.' description: '강좌 및 서비스 환경 설정'
}, },
profile: { profile: {
title: '프로필', title: '프로필',
+95 -57
View File
@@ -7,37 +7,39 @@ import type { InstructorCourse } from '../../lib/courses';
import type { AuthSession } from '../../types/auth'; import type { AuthSession } from '../../types/auth';
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = { const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
OPENAI: 'gpt-4.1-mini', A_COPILOT: 'a-copilot',
ANTHROPIC: 'claude-sonnet-4-5', OPENAI: 'gpt-6-astra',
GEMINI: 'gemini-2.5-flash', ANTHROPIC: 'claude-fable-5-1',
QWEN: 'qwen3.7-plus', GEMINI: 'gemini-4-argon',
QWEN: 'qwen3.8-max',
}; };
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'; const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
const CUSTOM_AI_MODEL = '__custom__'; const CUSTOM_AI_MODEL = '__custom__';
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = { const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
A_COPILOT: 'A-Copilot',
OPENAI: 'OpenAI', OPENAI: 'OpenAI',
ANTHROPIC: 'Claude', ANTHROPIC: 'Claude',
GEMINI: 'Gemini', GEMINI: 'Gemini',
QWEN: 'Qwen', QWEN: 'Qwen',
}; };
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = { const AI_MODEL_OPTIONS: Record<Exclude<CourseAiSetting['provider'], 'A_COPILOT'>, Array<{ value: string; label: string }>> = {
// Official provider model catalogs checked on 2026-10-19; keep existing selections intact. // Official provider model catalogs checked on 2026-10-19; keep existing selections intact.
OPENAI: [ OPENAI: [
{ value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
{ value: 'gpt-6-astra', label: 'GPT-6 Astra' }, { value: 'gpt-6-astra', label: 'GPT-6 Astra' },
{ value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
{ value: 'gpt-6-luna', label: 'GPT-6 Luna' }, { value: 'gpt-6-luna', label: 'GPT-6 Luna' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, { value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
{ value: 'gpt-4.1', label: 'GPT-4.1' }, { value: 'gpt-4.1', label: 'GPT-4.1' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
], ],
ANTHROPIC: [ 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-fable-5-1', label: 'Claude Fable 5.1' },
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
{ value: 'claude-haiku-5-5', label: 'Claude Haiku 5.5' },
{ value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ value: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, { value: 'claude-opus-4-1', label: 'Claude Opus 4.1' },
@@ -53,6 +55,7 @@ const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: strin
], ],
QWEN: [ QWEN: [
{ value: 'qwen3.8-max', label: 'Qwen 3.8 Max' }, { value: 'qwen3.8-max', label: 'Qwen 3.8 Max' },
{ value: 'qwen3.8-max-0902', label: 'Qwen 3.8 Max (0902)' },
{ value: 'qwen3.8-flash', label: 'Qwen 3.8 Flash' }, { value: 'qwen3.8-flash', label: 'Qwen 3.8 Flash' },
{ value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' }, { value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' },
{ value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' },
@@ -62,32 +65,44 @@ const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: strin
], ],
}; };
type AiSettingDraft = {
enabled: boolean;
model: string;
customModel: boolean;
endpointUrl: string;
apiKey: string;
hasApiKey: boolean;
};
function aiSettingDraft(provider: CourseAiSetting['provider'], setting?: CourseAiSetting): AiSettingDraft {
const model = provider === 'A_COPILOT' ? AI_MODEL_DEFAULTS.A_COPILOT : setting?.model ?? AI_MODEL_DEFAULTS[provider];
return {
enabled: setting?.enabled ?? false,
model,
customModel: provider !== 'A_COPILOT' && !AI_MODEL_OPTIONS[provider].some(option => option.value === model),
endpointUrl: setting?.endpointUrl || DEFAULT_QWEN_ENDPOINT,
apiKey: '',
hasApiKey: setting?.hasApiKey ?? false,
};
}
function aiSettingDrafts(settings: CourseAiSetting[] = []) {
return Object.fromEntries((Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][])
.map(provider => [provider, aiSettingDraft(provider, settings.find(setting => setting.provider === provider))])) as Record<CourseAiSetting['provider'], AiSettingDraft>;
}
type SettingsPageProps = { type SettingsPageProps = {
session: AuthSession; session: AuthSession;
selectedCourseId: string; selectedCourseId: string;
courses: InstructorCourse[]; courses: InstructorCourse[];
onSelectCourse: (courseId: string) => void;
}; };
export function SettingsPage({ session, selectedCourseId, courses, onSelectCourse }: SettingsPageProps) { export function SettingsPage({ session, selectedCourseId, courses }: SettingsPageProps) {
const selectedCourse = courses.find(course => course.id === selectedCourseId); const selectedCourse = courses.find(course => course.id === selectedCourseId);
return ( return (
<div className="mx-auto max-w-[1220px]"> <div className="mx-auto w-full max-w-[920px]">
<PageHeader route="settings" action={ <PageHeader route="settings" />
<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 ? ( {selectedCourse ? (
<CourseAiSettings key={`${session.user.id}:${session.accessToken}:${selectedCourseId}`} session={session} selectedCourseId={selectedCourseId} /> <CourseAiSettings key={`${session.user.id}:${session.accessToken}:${selectedCourseId}`} session={session} selectedCourseId={selectedCourseId} />
) : ( ) : (
@@ -102,30 +117,33 @@ export function SettingsPage({ session, selectedCourseId, courses, onSelectCours
function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) { function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) {
const { showToast } = useToast(); const { showToast } = useToast();
const [aiEnabled, setAiEnabled] = useState(false); const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('A_COPILOT');
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI'); const [drafts, setDrafts] = useState(aiSettingDrafts);
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI); const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false);
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 [aiSettingLoading, setAiSettingLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0); const [loadAttempt, setLoadAttempt] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mounted = useRef(false); const mounted = useRef(false);
const draft = drafts[aiProvider];
const aiEnabled = draft.enabled;
const aiModel = draft.model;
const aiCustomModel = draft.customModel;
const aiEndpointUrl = draft.endpointUrl;
const aiApiKey = draft.apiKey;
const aiHasApiKey = draft.hasApiKey;
const aiConfiguredProvider = aiHasApiKey ? aiProvider : null;
const updateDraft = (update: Partial<AiSettingDraft>) => setDrafts(current => ({ ...current, [aiProvider]: { ...current[aiProvider], ...update } }));
const setAiEnabled = (enabled: boolean) => updateDraft({ enabled });
const setAiModel = (model: string) => updateDraft({ model });
const setAiCustomModel = (customModel: boolean) => updateDraft({ customModel });
const setAiEndpointUrl = (endpointUrl: string) => updateDraft({ endpointUrl });
const setAiApiKey = (apiKey: string) => updateDraft({ apiKey });
const applySetting = (setting: CourseAiSetting) => { const applySetting = (setting: CourseAiSetting) => {
setAiEnabled(setting.enabled); setDrafts(current => ({ ...current, [setting.provider]: aiSettingDraft(setting.provider, setting) }));
setAiProvider(setting.provider); setAiCopilotConfigured(setting.copilotConfigured === true);
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(() => { useEffect(() => {
@@ -141,7 +159,9 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
getCourseAiSetting(session.accessToken, selectedCourseId) getCourseAiSetting(session.accessToken, selectedCourseId)
.then(setting => { .then(setting => {
if (cancelled) return; if (cancelled) return;
applySetting(setting); setDrafts(aiSettingDrafts(setting.settings ?? [setting]));
setAiProvider(setting.provider);
setAiCopilotConfigured(setting.copilotConfigured === true);
setLoaded(true); setLoaded(true);
}) })
.catch((reason: Error) => { .catch((reason: Error) => {
@@ -155,7 +175,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
const handleSaveAiSetting = async () => { const handleSaveAiSetting = async () => {
if (!loaded || aiSettingLoading || saving) return; if (!loaded || aiSettingLoading || saving) return;
if (!aiModel.trim() || aiModel.trim().length > 120) { if (aiProvider !== 'A_COPILOT' && (!aiModel.trim() || aiModel.trim().length > 120)) {
setError('모델 ID는 1~120자로 입력해주세요.'); setError('모델 ID는 1~120자로 입력해주세요.');
return; return;
} }
@@ -165,13 +185,13 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, { const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
enabled: aiEnabled, enabled: aiEnabled,
provider: aiProvider, provider: aiProvider,
model: aiModel.trim(), ...(aiProvider !== 'A_COPILOT' ? { model: aiModel.trim() } : {}),
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}), ...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}), ...(aiProvider !== 'A_COPILOT' && aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
}); });
if (!mounted.current) return; if (!mounted.current) return;
applySetting(setting); applySetting(setting);
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' }); showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? setting.provider === 'A_COPILOT' ? 'A-Copilot' : `${AI_PROVIDER_LABELS[setting.provider]} · ${setting.model}` : '사용 안 함' });
} catch (reason) { } catch (reason) {
if (mounted.current) setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.'); if (mounted.current) setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.');
} finally { } finally {
@@ -189,8 +209,8 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
<div className="flex items-center justify-between gap-4 pb-5"> <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> <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]"> <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]" /> <input type="checkbox" aria-label={`${AI_PROVIDER_LABELS[aiProvider]} 사용`} checked={aiEnabled} onChange={event => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
사용 {AI_PROVIDER_LABELS[aiProvider]} 사용
</label> </label>
</div> </div>
{aiSettingLoading ? ( {aiSettingLoading ? (
@@ -199,21 +219,19 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
<section className="border-t border-[#e2e8f0] pt-5"> <section className="border-t border-[#e2e8f0] pt-5">
<div> <div>
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span> <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 공급자"> <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5" role="radiogroup" aria-label="AI 공급자">
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => ( {(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
<button <button
key={provider} key={provider}
type="button" type="button"
role="radio" role="radio"
aria-label={AI_PROVIDER_LABELS[provider]}
aria-checked={aiProvider === provider} aria-checked={aiProvider === provider}
disabled={aiSettingLoading || saving} disabled={aiSettingLoading || saving}
onClick={() => { onClick={() => {
if (provider === aiProvider) return; if (provider === aiProvider) return;
setAiProvider(provider); setAiProvider(provider);
setAiModel(AI_MODEL_DEFAULTS[provider]); setError(null);
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 ${ className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
aiProvider === provider aiProvider === provider
@@ -221,11 +239,29 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
}`} }`}
> >
{AI_PROVIDER_LABELS[provider]} <span>{AI_PROVIDER_LABELS[provider]}</span>
<span className="mt-0.5 flex items-center justify-center gap-1 text-[10px] font-semibold">
<span className={`size-1.5 rounded-full ${drafts[provider].enabled ? 'bg-[#16a34a]' : 'bg-[#94a3b8]'}`} aria-hidden="true" />
{drafts[provider].enabled ? '사용' : '미사용'}
</span>
</button> </button>
))} ))}
</div> </div>
</div> </div>
{aiProvider === 'A_COPILOT' ? (
<div className="mt-4 grid gap-4">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">모델</span>
<input type="text" aria-label="모델" readOnly value="A-Copilot" className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
</label>
<label className="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="text" aria-label="API 키" readOnly value={aiCopilotConfigured ? '서버에서 관리' : '서버 미설정'} className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
</label>
{!aiCopilotConfigured ? <p role="alert" className="text-[12px] font-semibold text-[#be123c]">A-Copilot 서버 연결이 설정되어 있지 않습니다.</p> : null}
</div>
) : (
<>
<label className="mt-4 block"> <label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">모델</span> <span className="text-[12px] font-black text-[#475569]">모델</span>
<select <select
@@ -286,6 +322,8 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
/> />
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span> <span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
</label> </label>
</>
)}
</section> </section>
) : null} ) : null}
</fieldset> </fieldset>
@@ -296,9 +334,9 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
</div> </div>
) : null} ) : null}
<div className="flex justify-end border-t border-[#e2e8f0] px-5 py-4"> <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"> <button type="submit" disabled={!loaded || aiSettingLoading || saving || (aiProvider === 'A_COPILOT' ? aiEnabled && !aiCopilotConfigured : !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" />} {saving ? <Loader2 size={15} className="animate-spin" aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
AI 설정 저장 {AI_PROVIDER_LABELS[aiProvider]} 설정 저장
</button> </button>
</div> </div>
</form> </form>