313 lines
16 KiB
TypeScript
313 lines
16 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Bot, KeyRound, Loader2, RefreshCw, Save } from 'lucide-react';
|
|
import { PageHeader } from '../../components/PageHeader';
|
|
import { useToast } from '../../components/ToastProvider';
|
|
import { getCourseAiSetting, updateCourseAiSetting, type CourseAiSetting } from '../../lib/curriculum';
|
|
import type { InstructorCourse } from '../../lib/courses';
|
|
import type { AuthSession } from '../../types/auth';
|
|
|
|
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
|
|
A_COPILOT: 'a-copilot',
|
|
OPENAI: 'gpt-6-astra',
|
|
ANTHROPIC: 'claude-fable-5-1',
|
|
GEMINI: 'gemini-4-argon',
|
|
QWEN: 'qwen3.8-max',
|
|
};
|
|
|
|
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> = {
|
|
A_COPILOT: 'A-Copilot',
|
|
OPENAI: 'OpenAI',
|
|
ANTHROPIC: 'Claude',
|
|
GEMINI: 'Gemini',
|
|
QWEN: 'Qwen',
|
|
};
|
|
|
|
const AI_MODEL_OPTIONS: Record<Exclude<CourseAiSetting['provider'], 'A_COPILOT'>, Array<{ value: string; label: string }>> = {
|
|
// Official provider model catalogs checked on 2026-10-19; keep existing selections intact.
|
|
OPENAI: [
|
|
{ value: 'gpt-6-astra', label: 'GPT-6 Astra' },
|
|
{ value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
|
|
{ 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-fable-5-1', label: 'Claude Fable 5.1' },
|
|
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
|
|
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
|
|
{ value: 'claude-haiku-5-5', label: 'Claude Haiku 5.5' },
|
|
{ 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' },
|
|
{ value: 'qwen-vl-max', label: 'Qwen VL Max' },
|
|
{ value: 'qwen-plus', label: 'Qwen Plus (text)' },
|
|
],
|
|
};
|
|
|
|
type SettingsPageProps = {
|
|
session: AuthSession;
|
|
selectedCourseId: string;
|
|
courses: InstructorCourse[];
|
|
};
|
|
|
|
export function SettingsPage({ session, selectedCourseId, courses }: SettingsPageProps) {
|
|
const selectedCourse = courses.find(course => course.id === selectedCourseId);
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-[920px]">
|
|
<PageHeader route="settings" />
|
|
{selectedCourse ? (
|
|
<CourseAiSettings key={`${session.user.id}:${session.accessToken}:${selectedCourseId}`} session={session} selectedCourseId={selectedCourseId} />
|
|
) : (
|
|
<section className="border-y border-[#d1d9e6] bg-white px-5 py-8">
|
|
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
|
|
<p className="mt-3 text-[13px] font-semibold text-[#64748b]">설정할 강좌가 없습니다.</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) {
|
|
const { showToast } = useToast();
|
|
const [aiEnabled, setAiEnabled] = useState(false);
|
|
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('A_COPILOT');
|
|
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.A_COPILOT);
|
|
const [aiCustomModel, setAiCustomModel] = useState(false);
|
|
const [aiEndpointUrl, setAiEndpointUrl] = useState(DEFAULT_QWEN_ENDPOINT);
|
|
const [aiApiKey, setAiApiKey] = useState('');
|
|
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
|
const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false);
|
|
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
|
|
const [aiSettingLoading, setAiSettingLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const mounted = useRef(false);
|
|
|
|
const applySetting = (setting: CourseAiSetting) => {
|
|
setAiEnabled(setting.enabled);
|
|
setAiProvider(setting.provider);
|
|
setAiModel(setting.provider === 'A_COPILOT' ? AI_MODEL_DEFAULTS.A_COPILOT : setting.model);
|
|
setAiCustomModel(setting.provider !== 'A_COPILOT' && !AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
|
|
setAiEndpointUrl(setting.endpointUrl || DEFAULT_QWEN_ENDPOINT);
|
|
setAiHasApiKey(setting.hasApiKey);
|
|
setAiCopilotConfigured(setting.copilotConfigured === true);
|
|
setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null);
|
|
setAiApiKey('');
|
|
};
|
|
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
return () => { mounted.current = false; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setAiSettingLoading(true);
|
|
setLoaded(false);
|
|
setError(null);
|
|
getCourseAiSetting(session.accessToken, selectedCourseId)
|
|
.then(setting => {
|
|
if (cancelled) return;
|
|
applySetting(setting);
|
|
setLoaded(true);
|
|
})
|
|
.catch((reason: Error) => {
|
|
if (!cancelled) setError(reason.message);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setAiSettingLoading(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [session.accessToken, selectedCourseId, loadAttempt]);
|
|
|
|
const handleSaveAiSetting = async () => {
|
|
if (!loaded || aiSettingLoading || saving) return;
|
|
if (aiProvider !== 'A_COPILOT' && (!aiModel.trim() || aiModel.trim().length > 120)) {
|
|
setError('모델 ID는 1~120자로 입력해주세요.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
|
|
enabled: aiEnabled,
|
|
provider: aiProvider,
|
|
...(aiProvider !== 'A_COPILOT' ? { model: aiModel.trim() } : {}),
|
|
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
|
|
...(aiProvider !== 'A_COPILOT' && aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
|
|
});
|
|
if (!mounted.current) return;
|
|
applySetting(setting);
|
|
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? setting.provider === 'A_COPILOT' ? 'A-Copilot' : `${AI_PROVIDER_LABELS[setting.provider]} · ${setting.model}` : '사용 안 함' });
|
|
} catch (reason) {
|
|
if (mounted.current) setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.');
|
|
} finally {
|
|
if (mounted.current) setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
className="border-y border-[#d1d9e6] bg-white"
|
|
onSubmit={event => { event.preventDefault(); void handleSaveAiSetting(); }}
|
|
>
|
|
<fieldset disabled={!loaded || aiSettingLoading || saving} className="min-w-0 px-5 py-5">
|
|
<legend className="sr-only">AI 에이전트</legend>
|
|
<div className="flex items-center justify-between gap-4 pb-5">
|
|
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
|
|
<label className="inline-flex shrink-0 cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
|
|
<input type="checkbox" aria-label="AI 에이전트 사용" checked={aiEnabled} onChange={event => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
|
|
사용
|
|
</label>
|
|
</div>
|
|
{aiSettingLoading ? (
|
|
<p role="status" className="flex items-center gap-2 border-t border-[#e2e8f0] py-8 text-[13px] font-semibold text-[#64748b]"><Loader2 size={16} className="animate-spin" aria-hidden="true" />불러오는 중</p>
|
|
) : loaded ? (
|
|
<section className="border-t border-[#e2e8f0] pt-5">
|
|
<div>
|
|
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
|
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5" role="radiogroup" aria-label="AI 공급자">
|
|
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
|
|
<button
|
|
key={provider}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={aiProvider === provider}
|
|
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('');
|
|
}}
|
|
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
aiProvider === provider
|
|
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
|
|
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
|
|
}`}
|
|
>
|
|
{AI_PROVIDER_LABELS[provider]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{aiProvider === 'A_COPILOT' ? (
|
|
<div className="mt-4 grid gap-4">
|
|
<label className="block">
|
|
<span className="text-[12px] font-black text-[#475569]">모델</span>
|
|
<input type="text" aria-label="모델" readOnly value="A-Copilot" className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
|
|
<input type="text" aria-label="API 키" readOnly value={aiCopilotConfigured ? '서버에서 관리' : '서버 미설정'} className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
|
|
</label>
|
|
{!aiCopilotConfigured ? <p role="alert" className="text-[12px] font-semibold text-[#be123c]">A-Copilot 서버 연결이 설정되어 있지 않습니다.</p> : null}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<label className="mt-4 block">
|
|
<span className="text-[12px] font-black text-[#475569]">모델</span>
|
|
<select
|
|
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>
|
|
<input
|
|
type="url"
|
|
value={aiEndpointUrl}
|
|
onChange={(event) => setAiEndpointUrl(event.target.value)}
|
|
placeholder={DEFAULT_QWEN_ENDPOINT}
|
|
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]"
|
|
/>
|
|
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">OpenAI 호환 API의 기본 주소를 입력하세요. HTTPS 주소만 사용할 수 있습니다.</span>
|
|
</label>
|
|
) : null}
|
|
<label className="mt-4 block">
|
|
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={aiApiKey}
|
|
onChange={(event) => setAiApiKey(event.target.value)}
|
|
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
|
|
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]"
|
|
/>
|
|
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
|
|
</label>
|
|
</>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
</fieldset>
|
|
{error ? (
|
|
<div role="alert" className="mx-5 mb-5 flex flex-wrap items-center justify-between gap-3 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-semibold text-[#be123c]">
|
|
<span className="min-w-0 break-words">{error}</span>
|
|
{!loaded ? <button type="button" onClick={() => setLoadAttempt(attempt => attempt + 1)} className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#fda4af] px-3"><RefreshCw size={14} aria-hidden="true" />다시 시도</button> : null}
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-end border-t border-[#e2e8f0] px-5 py-4">
|
|
<button type="submit" disabled={!loaded || aiSettingLoading || saving || (aiProvider === 'A_COPILOT' ? aiEnabled && !aiCopilotConfigured : !aiModel.trim() || aiModel.trim().length > 120)} className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60">
|
|
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
|
|
AI 설정 저장
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|