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
+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 CUSTOM_AI_MODEL = '__custom__';
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
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 }>> = {
// 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<CourseAiSetting['provider']>('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
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">모델</span>
<select
value={aiModel}
disabled={aiSettingLoading}
onChange={(event) => setAiModel(event.target.value)}
aria-label="모델"
value={aiCustomModel ? CUSTOM_AI_MODEL : aiModel}
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]"
>
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
<option value={CUSTOM_AI_MODEL}>직접 입력</option>
</select>
</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' ? (
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">엔드포인트 주소</span>