feat: add custom AI model input and update provider model options

This commit is contained in:
2026-10-19 13:12:04 +09:00
parent 5327d178a7
commit 576fed06ac
2 changed files with 142 additions and 5 deletions
+91
View File
@@ -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);
});
+51 -5
View File
@@ -18,6 +18,7 @@ const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
}; };
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 AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = { const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
OPENAI: 'OpenAI', OPENAI: 'OpenAI',
@@ -27,22 +28,36 @@ const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
}; };
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = { const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
// 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-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-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' },
], ],
GEMINI: [ 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-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
], ],
QWEN: [ 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-plus', label: 'Qwen 3.7 Plus' },
{ value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' },
{ value: 'qwen-vl-plus', label: 'Qwen VL Plus' }, { 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 [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI'); const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI); const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
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);
@@ -204,7 +220,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]); const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]);
const hasGroups = groups.length > 0; const hasGroups = groups.length > 0;
const selectedWeekCompleted = selectedWeek?.state === 'done'; 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 selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? [];
const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? [];
const selectedWeekDeletable = selectedWeek?.canDelete === true; const selectedWeekDeletable = selectedWeek?.canDelete === true;
@@ -329,6 +345,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setAiEnabled(setting.enabled); setAiEnabled(setting.enabled);
setAiProvider(setting.provider); setAiProvider(setting.provider);
setAiModel(setting.model); setAiModel(setting.model);
setAiCustomModel(!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);
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
@@ -345,6 +362,10 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const handleSaveAiSetting = async () => { const handleSaveAiSetting = async () => {
if (!selectedCourseId) return; if (!selectedCourseId) return;
if (!aiModel.trim() || aiModel.trim().length > 120) {
setError('모델 ID는 1~120자로 입력해주세요.');
return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
@@ -358,6 +379,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setAiEnabled(setting.enabled); setAiEnabled(setting.enabled);
setAiProvider(setting.provider); setAiProvider(setting.provider);
setAiModel(setting.model); setAiModel(setting.model);
setAiCustomModel(!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);
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
@@ -1319,10 +1341,12 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
type="button" type="button"
role="radio" role="radio"
aria-checked={aiProvider === provider} aria-checked={aiProvider === provider}
disabled={aiSettingLoading} disabled={aiSettingLoading || saving}
onClick={() => { onClick={() => {
if (provider === aiProvider) return;
setAiProvider(provider); setAiProvider(provider);
setAiModel(AI_MODEL_DEFAULTS[provider]); setAiModel(AI_MODEL_DEFAULTS[provider]);
setAiCustomModel(false);
if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT); if (provider === 'QWEN' && !aiEndpointUrl.trim()) setAiEndpointUrl(DEFAULT_QWEN_ENDPOINT);
setAiApiKey(''); setAiApiKey('');
}} }}
@@ -1340,16 +1364,38 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
<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
value={aiModel} aria-label="모델"
disabled={aiSettingLoading} value={aiCustomModel ? CUSTOM_AI_MODEL : aiModel}
onChange={(event) => setAiModel(event.target.value)} disabled={aiSettingLoading || saving}
onChange={(event) => {
const custom = event.target.value === CUSTOM_AI_MODEL;
setAiCustomModel(custom);
setAiModel(custom ? '' : event.target.value);
}}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]" className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
> >
{AI_MODEL_OPTIONS[aiProvider].map((option) => ( {AI_MODEL_OPTIONS[aiProvider].map((option) => (
<option key={option.value} value={option.value}>{option.label}</option> <option key={option.value} value={option.value}>{option.label}</option>
))} ))}
<option value={CUSTOM_AI_MODEL}>직접 입력</option>
</select> </select>
</label> </label>
{aiCustomModel ? (
<label className="mt-3 block">
<span className="text-[12px] font-black text-[#475569]">모델 ID</span>
<input
type="text"
value={aiModel}
disabled={aiSettingLoading || saving}
onChange={(event) => setAiModel(event.target.value)}
maxLength={120}
autoComplete="off"
spellCheck={false}
placeholder={AI_MODEL_OPTIONS[aiProvider][0].value}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb] disabled:opacity-60"
/>
</label>
) : null}
{aiProvider === 'QWEN' ? ( {aiProvider === 'QWEN' ? (
<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>