feat: add custom AI model input and update provider model options
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user