platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -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', 'AI_PROVIDER_LABELS'].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, configured = true) {
|
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,
|
||||||
aiCopilotConfigured: configured,
|
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 };
|
||||||
@@ -65,9 +72,8 @@ test('provider defaults select the highest-tier current models at the top of eac
|
|||||||
for (const [provider, model] of Object.entries(expected)) {
|
for (const [provider, model] of Object.entries(expected)) {
|
||||||
assert.equal(AI_MODEL_DEFAULTS[provider], model);
|
assert.equal(AI_MODEL_DEFAULTS[provider], model);
|
||||||
assert.equal(AI_MODEL_OPTIONS[provider][0].value, model);
|
assert.equal(AI_MODEL_OPTIONS[provider][0].value, model);
|
||||||
const h = controls('QWEN', 'qwen3.7-plus');
|
const h = controls('A_COPILOT', 'a-copilot');
|
||||||
if (provider === 'QWEN') h.state.aiProvider = 'OPENAI';
|
elements(h.render(), node => node.props.role === 'radio' && node.props['aria-label'] === ({ OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' })[provider])[0].props.onClick();
|
||||||
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);
|
assert.equal(h.state.aiModel, model);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -81,13 +87,29 @@ 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.find(node => node.props.children === 'OpenAI').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.find(node => node.props.children === 'Claude').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);
|
||||||
@@ -95,7 +117,7 @@ test('the selected provider button preserves a custom draft', () => {
|
|||||||
|
|
||||||
test('A-Copilot is first and exposes only fixed read-only fields without a model picker', () => {
|
test('A-Copilot is first and exposes only fixed read-only fields without a model picker', () => {
|
||||||
const tree = controls('A_COPILOT', 'a-copilot').render();
|
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.props.role === 'radio')[0].props['aria-label'], 'A-Copilot');
|
||||||
assert.equal(elements(tree, node => node.type === 'select').length, 0);
|
assert.equal(elements(tree, node => node.type === 'select').length, 0);
|
||||||
const inputs = elements(tree, node => node.type === 'input');
|
const inputs = elements(tree, node => node.type === 'input');
|
||||||
assert.deepEqual(inputs.map(node => node.props.value), ['A-Copilot', '서버에서 관리']);
|
assert.deepEqual(inputs.map(node => node.props.value), ['A-Copilot', '서버에서 관리']);
|
||||||
@@ -112,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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export type CourseAiSetting = {
|
|||||||
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) {
|
||||||
@@ -145,12 +147,12 @@ 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(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const AI_MODEL_OPTIONS: Record<Exclude<CourseAiSetting['provider'], 'A_COPILOT'>
|
|||||||
],
|
],
|
||||||
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' },
|
||||||
@@ -64,6 +65,32 @@ const AI_MODEL_OPTIONS: Record<Exclude<CourseAiSetting['provider'], 'A_COPILOT'>
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -90,32 +117,33 @@ export function SettingsPage({ session, selectedCourseId, courses }: SettingsPag
|
|||||||
|
|
||||||
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']>('A_COPILOT');
|
||||||
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.A_COPILOT);
|
const [drafts, setDrafts] = useState(aiSettingDrafts);
|
||||||
const [aiCustomModel, setAiCustomModel] = useState(false);
|
|
||||||
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
|
||||||
const [aiApiKey, setAiApiKey] = useState('');
|
|
||||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
|
||||||
const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false);
|
const [aiCopilotConfigured, setAiCopilotConfigured] = 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);
|
|
||||||
setAiModel(setting.provider === 'A_COPILOT' ? AI_MODEL_DEFAULTS.A_COPILOT : setting.model);
|
|
||||||
setAiCustomModel(setting.provider !== 'A_COPILOT' && !AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
|
|
||||||
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
|
||||||
setAiHasApiKey(setting.hasApiKey);
|
|
||||||
setAiCopilotConfigured(setting.copilotConfigured === true);
|
setAiCopilotConfigured(setting.copilotConfigured === true);
|
||||||
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
|
||||||
setAiApiKey('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,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) => {
|
||||||
@@ -179,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 ? (
|
||||||
@@ -195,15 +225,13 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
|
|||||||
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
|
||||||
@@ -211,7 +239,11 @@ 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>
|
||||||
@@ -304,7 +336,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
|
|||||||
<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 || (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">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user