feat: add A-Copilot settings and refine AI configuration layout
This commit is contained in:
@@ -6,7 +6,7 @@ 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'].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 });
|
||||||
@@ -19,11 +19,11 @@ function find(node, predicate) {
|
|||||||
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, aiModel: model, aiCustomModel: custom };
|
||||||
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.aiModel = value; },
|
||||||
setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {},
|
setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {},
|
||||||
@@ -37,7 +37,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 +60,18 @@ 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('QWEN', 'qwen3.7-plus');
|
||||||
|
if (provider === 'QWEN') h.state.aiProvider = 'OPENAI';
|
||||||
|
elements(h.render(), node => node.props.role === 'radio' && node.props.children === ({ 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);
|
||||||
@@ -72,15 +84,26 @@ test('saved unknown model IDs remain visible in custom mode', () => {
|
|||||||
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.children === '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.children === '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.children, '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'],
|
||||||
|
|||||||
@@ -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
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,10 +115,11 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ export async function getCourseAiSetting(accessToken: string, courseId: string):
|
|||||||
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
@@ -36,7 +36,7 @@ export const pageMeta: Record<AppRoute, PageMeta> = {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '설정',
|
title: '설정',
|
||||||
description: '강좌별 AI 에이전트 설정을 관리합니다.'
|
description: '강좌 및 서비스 환경 설정'
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: '프로필',
|
title: '프로필',
|
||||||
|
|||||||
@@ -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' },
|
||||||
@@ -66,28 +68,14 @@ 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} />
|
||||||
) : (
|
) : (
|
||||||
@@ -103,12 +91,13 @@ 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 [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
|
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('A_COPILOT');
|
||||||
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
|
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.A_COPILOT);
|
||||||
const [aiCustomModel, setAiCustomModel] = useState(false);
|
const [aiCustomModel, setAiCustomModel] = useState(false);
|
||||||
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
||||||
const [aiApiKey, setAiApiKey] = useState('');
|
const [aiApiKey, setAiApiKey] = useState('');
|
||||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
||||||
|
const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false);
|
||||||
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
|
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);
|
||||||
@@ -120,10 +109,11 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
|
|||||||
const applySetting = (setting: CourseAiSetting) => {
|
const applySetting = (setting: CourseAiSetting) => {
|
||||||
setAiEnabled(setting.enabled);
|
setAiEnabled(setting.enabled);
|
||||||
setAiProvider(setting.provider);
|
setAiProvider(setting.provider);
|
||||||
setAiModel(setting.model);
|
setAiModel(setting.provider === 'A_COPILOT' ? AI_MODEL_DEFAULTS.A_COPILOT : setting.model);
|
||||||
setAiCustomModel(!AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
|
setAiCustomModel(setting.provider !== 'A_COPILOT' && !AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
|
||||||
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
||||||
setAiHasApiKey(setting.hasApiKey);
|
setAiHasApiKey(setting.hasApiKey);
|
||||||
|
setAiCopilotConfigured(setting.copilotConfigured === true);
|
||||||
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
||||||
setAiApiKey('');
|
setAiApiKey('');
|
||||||
};
|
};
|
||||||
@@ -155,7 +145,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 +155,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 {
|
||||||
@@ -199,7 +189,7 @@ 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}
|
||||||
@@ -226,6 +216,20 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
|
|||||||
))}
|
))}
|
||||||
</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 +290,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,7 +302,7 @@ 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 설정 저장
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user