feat: add A-Copilot settings and refine AI configuration layout

This commit is contained in:
2026-10-19 14:15:23 +09:00
parent cef1172fc1
commit 0b803a27ee
6 changed files with 89 additions and 57 deletions
+42 -36
View File
@@ -7,37 +7,39 @@ import type { InstructorCourse } from '../../lib/courses';
import type { AuthSession } from '../../types/auth';
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = {
OPENAI: 'gpt-4.1-mini',
ANTHROPIC: 'claude-sonnet-4-5',
GEMINI: 'gemini-2.5-flash',
QWEN: 'qwen3.7-plus',
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<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
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.1-sol', label: 'GPT-6.1 Sol' },
{ 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-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-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' },
@@ -66,28 +68,14 @@ type SettingsPageProps = {
session: AuthSession;
selectedCourseId: string;
courses: InstructorCourse[];
onSelectCourse: (courseId: string) => void;
};
export function SettingsPage({ session, selectedCourseId, courses, onSelectCourse }: SettingsPageProps) {
export function SettingsPage({ session, selectedCourseId, courses }: SettingsPageProps) {
const selectedCourse = courses.find(course => course.id === selectedCourseId);
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader route="settings" action={
<label className="grid w-full gap-1.5 text-[12px] font-bold text-[#475569] sm:w-[280px]">
강좌
<select
aria-label="강좌"
className="h-10 w-full min-w-0 rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb] disabled:opacity-60"
value={selectedCourseId}
disabled={!courses.length}
onChange={event => onSelectCourse(event.target.value)}
>
{courses.length ? courses.map(course => <option key={course.id} value={course.id}>{course.title}</option>) : <option value="">등록된 강좌 없음</option>}
</select>
</label>
} />
<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} />
) : (
@@ -103,12 +91,13 @@ export function SettingsPage({ session, selectedCourseId, courses, onSelectCours
function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) {
const { showToast } = useToast();
const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('OPENAI');
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
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);
@@ -120,10 +109,11 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
const applySetting = (setting: CourseAiSetting) => {
setAiEnabled(setting.enabled);
setAiProvider(setting.provider);
setAiModel(setting.model);
setAiCustomModel(!AI_MODEL_OPTIONS[setting.provider].some(option => option.value === setting.model));
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('');
};
@@ -155,7 +145,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
const handleSaveAiSetting = async () => {
if (!loaded || aiSettingLoading || saving) return;
if (!aiModel.trim() || aiModel.trim().length > 120) {
if (aiProvider !== 'A_COPILOT' && (!aiModel.trim() || aiModel.trim().length > 120)) {
setError('모델 ID는 1~120자로 입력해주세요.');
return;
}
@@ -165,13 +155,13 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
enabled: aiEnabled,
provider: aiProvider,
model: aiModel.trim(),
...(aiProvider !== 'A_COPILOT' ? { model: aiModel.trim() } : {}),
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
...(aiProvider !== 'A_COPILOT' && aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
});
if (!mounted.current) return;
applySetting(setting);
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' });
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 {
@@ -199,7 +189,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
<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-4" role="radiogroup" aria-label="AI 공급자">
<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}
@@ -226,6 +216,20 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
))}
</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
@@ -286,6 +290,8 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
/>
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
</label>
</>
)}
</section>
) : null}
</fieldset>
@@ -296,7 +302,7 @@ function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps,
</div>
) : null}
<div className="flex justify-end border-t border-[#e2e8f0] px-5 py-4">
<button type="submit" disabled={!loaded || aiSettingLoading || saving || !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">
<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>