'use client' import Link from 'next/link' import { useEffect, useState } from 'react' import { clearStoredApiKey, clearStoredProviderConfig, getStoredApiKey, getStoredProviderConfig, maskApiKey, setStoredProviderConfig, } from '@/lib/api-key-storage' import { DEFAULT_PRESET_ID, PROVIDER_PRESETS, findPreset, } from '@/lib/providers' type TestState = | { status: 'idle' } | { status: 'testing' } | { status: 'success'; message: string; model?: string; reply?: string } | { status: 'failed'; message: string } export default function SettingsPage() { const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID) const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [model, setModel] = useState('') const [showKey, setShowKey] = useState(false) const [savedKey, setSavedKey] = useState(null) const [envConfigured, setEnvConfigured] = useState(null) const [testState, setTestState] = useState({ status: 'idle' }) const [savedToast, setSavedToast] = useState(false) const preset = findPreset(presetId) const isOpenAiCompatible = preset.provider === 'openai-compatible' useEffect(() => { const stored = getStoredProviderConfig() if (stored) { const storedPreset = findPreset(stored.presetId) setPresetId(storedPreset.id) setApiKey(stored.apiKey) setBaseUrl(stored.baseUrl) setModel(stored.model) setSavedKey(stored.apiKey || null) } else { // 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다. const legacyKey = getStoredApiKey() setApiKey(legacyKey ?? '') setModel(findPreset(DEFAULT_PRESET_ID).defaultModel) setSavedKey(legacyKey) } fetch('/api/settings/status') .then((r) => r.json()) .then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured)) .catch(() => setEnvConfigured(false)) }, []) function handlePresetChange(nextId: string) { const next = findPreset(nextId) if (next.id === presetId) return setPresetId(next.id) setBaseUrl(next.baseUrl) setModel(next.defaultModel) // 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면 // 다른 회사 엔드포인트로 전송될 수 있으므로 비운다. setApiKey('') setTestState({ status: 'idle' }) } function handleSave() { setStoredProviderConfig({ presetId, apiKey: apiKey.trim(), baseUrl: baseUrl.trim(), model: model.trim(), }) setSavedKey(apiKey.trim() || null) setSavedToast(true) setTimeout(() => setSavedToast(false), 2000) } function handleClear() { if ( !confirm( '브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.', ) ) { return } clearStoredProviderConfig() clearStoredApiKey() const fallback = findPreset(DEFAULT_PRESET_ID) setPresetId(fallback.id) setApiKey('') setBaseUrl(fallback.baseUrl) setModel(fallback.defaultModel) setSavedKey(null) setTestState({ status: 'idle' }) } async function handleTest() { setTestState({ status: 'testing' }) try { const res = await fetch('/api/settings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: preset.provider, apiKey: apiKey.trim(), baseUrl: baseUrl.trim(), model: model.trim(), }), }) const data = await res.json() if (data.ok) { setTestState({ status: 'success', message: data.message, model: data.model, reply: data.reply, }) } else { setTestState({ status: 'failed', message: data.error ?? '실패' }) } } catch (err) { setTestState({ status: 'failed', message: err instanceof Error ? err.message : '네트워크 오류', }) } } const canTest = testState.status !== 'testing' && (preset.apiKeyOptional || apiKey.trim().length > 0) && (!isOpenAiCompatible || (baseUrl.trim().length > 0 && model.trim().length > 0)) const activeSource = savedKey ? '브라우저 (LocalStorage)' : envConfigured ? '환경변수 (서버)' : '없음' const activeBadgeStyle = savedKey ? 'bg-purple-100 text-purple-700' : envConfigured ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' return (
← 홈으로 📚 저장된 회의록

⚙️ 설정

AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에 저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.

현재 설정

{activeSource}

프로바이더:{' '} {preset.label}

모델:{' '} {model.trim() ? ( {model.trim()} ) : ( 기본값 )}

브라우저 키:{' '} {savedKey ? ( {maskApiKey(savedKey)} ) : ( 미설정 )}

환경변수:{' '} {envConfigured === null ? ( 확인 중... ) : envConfigured ? ( 설정됨 ) : ( 미설정 )}

💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백

프로바이더

{PROVIDER_PRESETS.map((item) => ( ))}

{preset.description}

{isOpenAiCompatible && (
setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all" />

OpenAI 호환 엔드포인트의 /chat/completions 앞부분까지 입력하세요.

)}
setModel(e.target.value)} placeholder={preset.defaultModel || 'model-id'} className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all" /> {preset.modelHint && (

{preset.modelHint}

)}
setApiKey(e.target.value)} placeholder={preset.apiKeyPlaceholder} className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all" />
{preset.docsUrl && (

{preset.docsLabel ?? preset.docsUrl} 에서 발급할 수 있습니다.

)}
{savedToast && (
✓ 브라우저에 저장되었습니다.
)} {testState.status === 'success' && (
✓ {testState.message}

{testState.model ? `${testState.model} → ` : ''} {testState.reply}

)} {testState.status === 'failed' && (
✗ {testState.message}
)}

보안 안내

  • 키는 이 브라우저의 LocalStorage에만 저장됩니다 (서버 DB에 저장되지 않음).
  • 요약 요청 시 body로 함께 전송되어 일회성으로 사용됩니다.
  • 다른 브라우저/기기에서는 다시 입력해야 합니다.
  • 시크릿 모드 종료, 캐시 삭제 시 사라집니다.
  • HTTPS 환경에서 사용하는 것을 권장합니다 (현재 localhost는 예외).
  • ⚠️ 공유 PC에서는 사용 후 반드시 🗑️ 삭제 버튼을 눌러주세요. 로그인 상태로 자리를 비우면 다른 사용자가 개발자 도구로 LocalStorage를 직접 열어 키를 추출할 수 있습니다.
  • ⚠️ 회의 내용에 민감 정보가 포함된 경우, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
  • ⚠️ 중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다. OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 로컬 모델을 선택하세요.
) }