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