From 1dd065ec79d604aa1698f31a82425550cf72295b Mon Sep 17 00:00:00 2001 From: web-admin Date: Tue, 20 Oct 2026 03:34:17 +0000 Subject: [PATCH] platform/lms-adm-fe release promotion snapshot --- scripts/test-ai-model-picker.cjs | 74 +++++++++++++++++++++----- src/lib/curriculum.ts | 6 ++- src/pages/settings/SettingsPage.tsx | 80 ++++++++++++++++++++--------- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/scripts/test-ai-model-picker.cjs b/scripts/test-ai-model-picker.cjs index b099305..ce41175 100644 --- a/scripts/test-ai-model-picker.cjs +++ b/scripts/test-ai-model-picker.cjs @@ -6,27 +6,34 @@ const ts = require('typescript'); const React = require('react'); const source = ts.createSourceFile('SettingsPage.tsx', fs.readFileSync(require.resolve('../src/pages/settings/SettingsPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL', '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 = {}) { const exports = {}; vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals }); return exports; } -const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n'); -const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs); +const helpers = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['aiSettingDraft', 'aiSettingDrafts'].includes(node.name?.text)); +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) { if (predicate(node)) return node; return ts.forEachChild(node, child => find(child, predicate)); } const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자')); function controls(provider, model, custom = false, 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 = { aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null, aiCopilotConfigured: configured, DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null, - setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; }, - setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {}, + setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.drafts[state.aiProvider].model = value; }, + 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; 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)) { 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(); + 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); } }); @@ -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); }); +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', () => { const h = controls('OPENAI', 'custom-gpt', true); 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.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.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC); 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', () => { 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); const inputs = elements(tree, node => node.type === 'input'); 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'], })) 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); +}); diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index 267faac..32b4a91 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -123,6 +123,8 @@ export type CourseAiSetting = { 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(/\/+$/, ''); function uploadErrorMessage(status: number, fallback: string) { @@ -145,12 +147,12 @@ export async function getInstructorCurriculum(accessToken: string, groupId?: str return response.json() as Promise; } -export async function getCourseAiSetting(accessToken: string, courseId: string): Promise { +export async function getCourseAiSetting(accessToken: string, courseId: string): Promise { const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) throw new Error(`AI 에이전트 설정을 불러오지 못했습니다. (${response.status})`); - return response.json() as Promise; + return response.json() as Promise; } export async function updateCourseAiSetting( diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 43919c7..cb79c43 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -55,6 +55,7 @@ const AI_MODEL_OPTIONS: Record ], QWEN: [ { 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.7-plus', label: 'Qwen 3.7 Plus' }, { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, @@ -64,6 +65,32 @@ const AI_MODEL_OPTIONS: Record ], }; +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; +} + type SettingsPageProps = { session: AuthSession; selectedCourseId: string; @@ -90,32 +117,33 @@ export function SettingsPage({ session, selectedCourseId, courses }: SettingsPag function CourseAiSettings({ session, selectedCourseId }: Pick) { const { showToast } = useToast(); - const [aiEnabled, setAiEnabled] = useState(false); const [aiProvider, setAiProvider] = useState('A_COPILOT'); - const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.A_COPILOT); - const [aiCustomModel, setAiCustomModel] = useState(false); - const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT); - const [aiApiKey, setAiApiKey] = useState(''); - const [aiHasApiKey, setAiHasApiKey] = useState(false); + const [drafts, setDrafts] = useState(aiSettingDrafts); const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false); - const [aiConfiguredProvider, setAiConfiguredProvider] = useState(null); const [aiSettingLoading, setAiSettingLoading] = useState(true); const [saving, setSaving] = useState(false); const [loaded, setLoaded] = useState(false); const [loadAttempt, setLoadAttempt] = useState(0); const [error, setError] = useState(null); 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) => 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) => { - setAiEnabled(setting.enabled); - 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); + setDrafts(current => ({ ...current, [setting.provider]: aiSettingDraft(setting.provider, setting) })); setAiCopilotConfigured(setting.copilotConfigured === true); - setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); - setAiApiKey(''); }; useEffect(() => { @@ -131,7 +159,9 @@ function CourseAiSettings({ session, selectedCourseId }: Pick { if (cancelled) return; - applySetting(setting); + setDrafts(aiSettingDrafts(setting.settings ?? [setting])); + setAiProvider(setting.provider); + setAiCopilotConfigured(setting.copilotConfigured === true); setLoaded(true); }) .catch((reason: Error) => { @@ -179,8 +209,8 @@ function CourseAiSettings({ session, selectedCourseId }: Pick

{aiSettingLoading ? ( @@ -195,15 +225,13 @@ function CourseAiSettings({ session, selectedCourseId }: Pick { if (provider === aiProvider) return; setAiProvider(provider); - setAiModel(AI_MODEL_DEFAULTS[provider]); - setAiCustomModel(false); - if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT); - setAiApiKey(''); + setError(null); }} className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${ aiProvider === provider @@ -211,7 +239,11 @@ function CourseAiSettings({ session, selectedCourseId }: Pick - {AI_PROVIDER_LABELS[provider]} + {AI_PROVIDER_LABELS[provider]} + + ))} @@ -304,7 +336,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick