From 576fed06acbb8311b73e8ed009bba61bab8eabc4 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 19 Oct 2026 13:12:04 +0900 Subject: [PATCH] feat: add custom AI model input and update provider model options --- scripts/test-ai-model-picker.cjs | 91 +++++++++++++++++++++++++ src/pages/curriculum/CurriculumPage.tsx | 56 +++++++++++++-- 2 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 scripts/test-ai-model-picker.cjs diff --git a/scripts/test-ai-model-picker.cjs b/scripts/test-ai-model-picker.cjs new file mode 100644 index 0000000..1b70316 --- /dev/null +++ b/scripts/test-ai-model-picker.cjs @@ -0,0 +1,91 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const React = require('react'); + +const source = ts.createSourceFile('CurriculumPage.tsx', fs.readFileSync(require.resolve('../src/pages/curriculum/CurriculumPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL'].includes(node.declarationList.declarations[0].name.getText(source))); +function compile(code, globals = {}) { + const exports = {}; + vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals }); + return exports; +} +const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n'); +const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs); +function find(node, predicate) { + if (predicate(node)) return node; + return ts.forEachChild(node, child => find(child, predicate)); +} +const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자')); +function controls(provider, model, custom = false) { + const state = { aiProvider: provider, aiModel: model, aiCustomModel: custom }; + const context = { + aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null, + AI_PROVIDER_LABELS: { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' }, + DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null, + setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; }, + setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {}, + }; + const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree; + return { state, render }; +} +function elements(root, predicate) { + if (!React.isValidElement(root)) return []; + return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]; +} + +for (const provider of Object.keys(AI_MODEL_OPTIONS)) { + test(`${provider} offers current models and a custom model field without changing defaults`, () => { + const h = controls(provider, AI_MODEL_DEFAULTS[provider]); + let tree = h.render(); + const select = elements(tree, node => node.type === 'select')[0]; + assert.equal(select.props.value, AI_MODEL_DEFAULTS[provider]); + assert.equal(elements(select, node => node.type === 'option' && node.props.value === CUSTOM_AI_MODEL).length, 1); + assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text').length, 0); + select.props.onChange({ target: { value: CUSTOM_AI_MODEL } }); + assert.equal(h.state.aiCustomModel, true); + assert.equal(h.state.aiModel, ''); + tree = h.render(); + const input = elements(tree, node => node.type === 'input' && node.props.type === 'text')[0]; + assert.equal(input.props.maxLength, 120); + input.props.onChange({ target: { value: 'custom-model-2026' } }); + assert.equal(h.state.aiModel, 'custom-model-2026'); + assert.equal(elements(h.render(), node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL); + elements(h.render(), node => node.type === 'select')[0].props.onChange({ target: { value: AI_MODEL_OPTIONS[provider][0].value } }); + assert.equal(h.state.aiCustomModel, false); + assert.equal(h.state.aiModel, AI_MODEL_OPTIONS[provider][0].value); + assert.equal(new Set(AI_MODEL_OPTIONS[provider].map(option => option.value)).size, AI_MODEL_OPTIONS[provider].length); + }); +} + +test('saved unknown model IDs remain visible in custom mode', () => { + const model = 'provider/private-model:revision-10'; + const h = controls('QWEN', model, true); + const tree = h.render(); + assert.equal(elements(tree, node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL); + assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text')[0].props.value, model); + assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false); +}); + +test('the selected provider button preserves a custom draft', () => { + const h = controls('OPENAI', 'custom-gpt', true); + const providers = elements(h.render(), node => node.props.role === 'radio'); + providers[0].props.onClick(); + assert.equal(h.state.aiModel, 'custom-gpt'); + assert.equal(h.state.aiCustomModel, true); + providers[1].props.onClick(); + assert.equal(h.state.aiProvider, 'ANTHROPIC'); + assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC); + assert.equal(h.state.aiCustomModel, false); +}); + +test('official current API IDs are present', () => { + for (const [provider, ids] of Object.entries({ + OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'], + ANTHROPIC: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'], + GEMINI: ['gemini-4-argon', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'], + QWEN: ['qwen3.8-max', 'qwen3.8-flash'], + })) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id); +}); diff --git a/src/pages/curriculum/CurriculumPage.tsx b/src/pages/curriculum/CurriculumPage.tsx index 89de717..8de4653 100644 --- a/src/pages/curriculum/CurriculumPage.tsx +++ b/src/pages/curriculum/CurriculumPage.tsx @@ -18,6 +18,7 @@ const AI_MODEL_DEFAULTS: Record = { }; const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'; +const CUSTOM_AI_MODEL = '__custom__'; const AI_PROVIDER_LABELS: Record = { OPENAI: 'OpenAI', @@ -27,22 +28,36 @@ const AI_PROVIDER_LABELS: Record = { }; const AI_MODEL_OPTIONS: Record> = { + // Official provider model catalogs checked on 2026-10-19; keep existing selections intact. OPENAI: [ + { value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' }, + { value: 'gpt-6-astra', label: 'GPT-6 Astra' }, + { value: 'gpt-6-luna', label: 'GPT-6 Luna' }, { value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, { value: 'gpt-4.1', label: 'GPT-4.1' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, ], ANTHROPIC: [ + { value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' }, + { value: 'claude-opus-5-5', label: 'Claude Opus 5.5' }, + { value: 'claude-haiku-5-5', label: 'Claude Haiku 5.5' }, + { value: 'claude-fable-5-1', label: 'Claude Fable 5.1' }, { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, { value: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, ], GEMINI: [ + { value: 'gemini-4-argon', label: 'Gemini 4 Argon' }, + { value: 'gemini-3.8-flash', label: 'Gemini 3.8 Flash' }, + { value: 'gemini-3.5-flash-lite', label: 'Gemini 3.5 Flash-Lite' }, + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro (Preview)' }, { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, ], QWEN: [ + { value: 'qwen3.8-max', label: 'Qwen 3.8 Max' }, + { value: 'qwen3.8-flash', label: 'Qwen 3.8 Flash' }, { value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' }, { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, { value: 'qwen-vl-plus', label: 'Qwen VL Plus' }, @@ -189,6 +204,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const [aiEnabled, setAiEnabled] = useState(false); const [aiProvider, setAiProvider] = useState('OPENAI'); const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI); + const [aiCustomModel, setAiCustomModel] = useState(false); const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT); const [aiApiKey, setAiApiKey] = useState(''); const [aiHasApiKey, setAiHasApiKey] = useState(false); @@ -204,7 +220,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]); const hasGroups = groups.length > 0; const selectedWeekCompleted = selectedWeek?.state === 'done'; - const primaryActionDisabled = saving || aiSettingLoading || (activeTab !== 'ai' && !selectedWeek) || (activeTab === 'apply' && !selectedGroup); + const primaryActionDisabled = saving || aiSettingLoading || (activeTab === 'ai' && (!aiModel.trim() || aiModel.trim().length > 120)) || (activeTab !== 'ai' && !selectedWeek) || (activeTab === 'apply' && !selectedGroup); const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedWeekDeletable = selectedWeek?.canDelete === true; @@ -329,6 +345,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes setAiEnabled(setting.enabled); setAiProvider(setting.provider); setAiModel(setting.model); + setAiCustomModel(!AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model)); setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT); setAiHasApiKey(setting.hasApiKey); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); @@ -345,6 +362,10 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const handleSaveAiSetting = async () => { if (!selectedCourseId) return; + if (!aiModel.trim() || aiModel.trim().length > 120) { + setError('모델 ID는 1~120자로 입력해주세요.'); + return; + } setSaving(true); setError(null); try { @@ -358,6 +379,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes setAiEnabled(setting.enabled); setAiProvider(setting.provider); setAiModel(setting.model); + setAiCustomModel(!AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model)); setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT); setAiHasApiKey(setting.hasApiKey); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); @@ -1319,10 +1341,12 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes type="button" role="radio" aria-checked={aiProvider === provider} - disabled={aiSettingLoading} + disabled={aiSettingLoading || saving} onClick={() => { + if (provider === aiProvider) return; setAiProvider(provider); setAiModel(AI_MODEL_DEFAULTS[provider]); + setAiCustomModel(false); if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT); setAiApiKey(''); }} @@ -1340,16 +1364,38 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes + {aiCustomModel ? ( + + ) : null} {aiProvider === 'QWEN' ? (