2026-06-28 23:48:13 +00:00
|
|
|
<template>
|
2026-07-05 09:12:04 +00:00
|
|
|
<div class="p-8 max-w-4xl mx-auto space-y-8">
|
2026-06-28 23:48:13 +00:00
|
|
|
<!-- Header -->
|
|
|
|
|
<div class="flex items-center justify-between pb-6 border-b border-base-content/10">
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
<div class="bg-primary/10 p-3 rounded-2xl text-primary shadow-lg shadow-primary/5">
|
|
|
|
|
<KeyIcon :size="28" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-3xl font-black tracking-tight">Gemini API 설정</h1>
|
|
|
|
|
<p class="text-sm opacity-60 mt-1">상세 해설 생성 및 주관식 채점에 사용되는 API 키를 여러 개 관리합니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Preferred Model Config Section -->
|
|
|
|
|
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-4">
|
|
|
|
|
<h2 class="text-lg font-bold flex items-center gap-2">
|
|
|
|
|
<SparklesIcon :size="20" class="text-primary" />
|
|
|
|
|
우선 시도 모델 설정
|
|
|
|
|
</h2>
|
|
|
|
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
|
|
|
<select
|
|
|
|
|
v-model="preferredModel"
|
|
|
|
|
@change="handleModelChange"
|
|
|
|
|
class="select select-bordered w-full sm:w-80 bg-base-100 font-medium focus:select-primary"
|
|
|
|
|
>
|
|
|
|
|
<option value="gemini-3.1-flash-lite">gemini-3.1-flash-lite (Lite / 권장)</option>
|
|
|
|
|
<option value="gemini-3.5-flash">gemini-3.5-flash</option>
|
|
|
|
|
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
|
|
|
|
|
<option value="gemini-2.0-flash-lite">gemini-2.0-flash-lite</option>
|
|
|
|
|
<option value="gemini-1.5-flash">gemini-1.5-flash</option>
|
|
|
|
|
</select>
|
|
|
|
|
<p class="text-xs opacity-60 leading-normal">
|
|
|
|
|
API 호출 시 여기서 선택한 우선시도 모델이 가장 먼저 적용됩니다.<br />
|
|
|
|
|
만약 에러가 발생할 경우, 남은 키로 시도한 후 다른 모델로 순차 폴백(fallback)합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- API Key Input Section -->
|
|
|
|
|
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-4">
|
|
|
|
|
<h2 class="text-lg font-bold flex items-center gap-2">
|
|
|
|
|
<PlusCircleIcon :size="20" class="text-primary" />
|
|
|
|
|
새 API 키 추가
|
|
|
|
|
</h2>
|
|
|
|
|
<div class="flex flex-col md:flex-row gap-3">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
v-model="newKey"
|
|
|
|
|
placeholder="AIzaSy..."
|
|
|
|
|
class="input input-bordered flex-1 bg-base-100 font-mono text-sm focus:input-primary"
|
|
|
|
|
@keyup.enter="handleAddKey"
|
|
|
|
|
/>
|
|
|
|
|
<button class="btn btn-primary px-8 font-bold" @click="handleAddKey" :disabled="!newKey.trim()">
|
|
|
|
|
<PlusIcon :size="18" class="mr-1" />
|
|
|
|
|
추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-xs opacity-50 px-1">
|
|
|
|
|
입력한 키는 로컬 데이터베이스에 안전하게 저장됩니다. 여러 키가 등록되면 무작위로 선택되어 분산 처리됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- API Keys List -->
|
|
|
|
|
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-6">
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<h2 class="text-lg font-bold flex items-center gap-2">
|
|
|
|
|
<ListIcon :size="20" class="text-primary" />
|
|
|
|
|
등록된 API 키 목록
|
|
|
|
|
<div class="badge badge-primary badge-sm font-black">{{ keys.length }}</div>
|
|
|
|
|
</h2>
|
|
|
|
|
<button class="btn btn-ghost btn-xs opacity-60 hover:opacity-100" @click="fetchKeys">
|
|
|
|
|
새로고침
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="loading" class="flex justify-center py-8">
|
|
|
|
|
<span class="loading loading-spinner loading-md text-primary"></span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else-if="keys.length === 0" class="text-center py-12 border-2 border-dashed border-base-content/10 rounded-2xl">
|
|
|
|
|
<KeyRoundIcon :size="48" class="mx-auto opacity-20 mb-3" />
|
|
|
|
|
<p class="font-bold opacity-60">등록된 API 키가 없습니다.</p>
|
|
|
|
|
<p class="text-xs opacity-40 mt-1">상단의 입력란을 통해 첫 번째 API 키를 추가해 주세요.</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="space-y-3">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(key, index) in keys"
|
|
|
|
|
:key="key"
|
|
|
|
|
class="flex items-center justify-between p-4 bg-base-100 border border-base-content/5 rounded-2xl hover:border-primary/20 transition-all group"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-4 overflow-hidden">
|
|
|
|
|
<span class="text-xs font-mono opacity-40 shrink-0">#{{ index + 1 }}</span>
|
|
|
|
|
<div class="font-mono text-sm tracking-wider truncate select-all">
|
|
|
|
|
{{ maskKey(key) }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-error btn-ghost btn-sm btn-square opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
|
|
|
|
@click="handleDeleteKey(key)"
|
|
|
|
|
title="삭제"
|
|
|
|
|
>
|
|
|
|
|
<Trash2Icon :size="16" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Retry logic explanation -->
|
|
|
|
|
<div class="bg-primary/5 border border-primary/10 rounded-3xl p-6 space-y-3">
|
|
|
|
|
<h3 class="font-black text-primary flex items-center gap-2">
|
|
|
|
|
<ShieldAlertIcon :size="18" />
|
|
|
|
|
작동 프로세스 (자동 모델/키 폴백)
|
|
|
|
|
</h3>
|
|
|
|
|
<ul class="text-xs space-y-2 opacity-85 list-disc pl-5 leading-relaxed">
|
|
|
|
|
<li>등록된 API 키 목록을 <strong>랜덤하게 섞은(shuffle) 후</strong> 순차적으로 API 호출을 시도합니다.</li>
|
|
|
|
|
<li>특정 키에서 호출 에러(예: 429 Rate Limit 등)가 발생하면, 대기나 내부 재시도 없이 <strong>즉시 남은 다른 키로 순서대로 자동 재시도</strong>합니다.</li>
|
|
|
|
|
<li>모든 키에 대해 에러가 발생하면, 선택된 <strong>우선 시도 모델</strong>부터 시작하여 나머지 지원 모델(<strong>gemini-3.1-flash-lite → gemini-3.5-flash → gemini-2.5-flash → gemini-2.0-flash-lite → gemini-1.5-flash</strong>)로 단계를 순차적으로 낮춰가며 다시 키별 즉시 시도를 실행합니다.</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, onMounted } from 'vue'
|
|
|
|
|
import { KeyIcon, PlusCircleIcon, PlusIcon, ListIcon, KeyRoundIcon, Trash2Icon, ShieldAlertIcon, SparklesIcon } from 'lucide-vue-next'
|
|
|
|
|
import { GetGeminiKeys, AddGeminiKey, DeleteGeminiKey, GetPreferredModel, SetPreferredModel } from '../../bindings/changeme/quizservice'
|
|
|
|
|
import { useQuizStore } from '../store/quiz'
|
|
|
|
|
import { useAuthStore } from '../store/auth'
|
|
|
|
|
|
|
|
|
|
const store = useQuizStore()
|
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
|
const keys = ref<string[]>([])
|
|
|
|
|
const newKey = ref('')
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const preferredModel = ref('gemini-3.1-flash-lite')
|
|
|
|
|
|
|
|
|
|
async function fetchKeys() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const list = await GetGeminiKeys()
|
|
|
|
|
keys.value = list || []
|
|
|
|
|
store.setGeminiKeysCount(keys.value.length)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch Gemini API keys:', err)
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchPreferredModel() {
|
|
|
|
|
try {
|
|
|
|
|
const model = await GetPreferredModel()
|
|
|
|
|
if (model) {
|
|
|
|
|
preferredModel.value = model
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch preferred Gemini model:', err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleModelChange() {
|
|
|
|
|
try {
|
|
|
|
|
await SetPreferredModel(preferredModel.value)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`우선 모델 저장 실패: ${err}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAddKey() {
|
|
|
|
|
const trimmed = newKey.value.trim()
|
|
|
|
|
if (!trimmed) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await AddGeminiKey(trimmed)
|
|
|
|
|
newKey.value = ''
|
|
|
|
|
await fetchKeys()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`API 키 추가 실패: ${err}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDeleteKey(key: string) {
|
|
|
|
|
if (!confirm('이 API 키를 삭제하시겠습니까?')) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await DeleteGeminiKey(key)
|
|
|
|
|
await fetchKeys()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`API 키 삭제 실패: ${err}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function maskKey(key: string): string {
|
|
|
|
|
if (key.length <= 8) return '****'
|
|
|
|
|
return `${key.slice(0, 6)}...${key.slice(-4)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
fetchKeys()
|
|
|
|
|
fetchPreferredModel()
|
|
|
|
|
})
|
|
|
|
|
</script>
|