This commit is contained in:
arhat0307
2026-06-26 00:47:31 +09:00
parent 75f9c5d7d4
commit f32944980f
13 changed files with 1276 additions and 117 deletions
@@ -13,6 +13,14 @@ export function Configure(request: $models.ConfigureRequest): $CancellablePromis
return $Call.ByID(1065098514, request);
}
export function FinalizeSession(sessionID: string): $CancellablePromise<$models.ExamReport> {
return $Call.ByID(3685135372, sessionID);
}
export function GenerateSpeech(text: string): $CancellablePromise<$models.SpeechResponse> {
return $Call.ByID(2776553183, text);
}
export function GetReport(sessionID: string): $CancellablePromise<$models.ExamReport> {
return $Call.ByID(704064164, sessionID);
}
+2
View File
@@ -13,8 +13,10 @@ export type {
ConfigureRequest,
ExamConfig,
ExamReport,
GradePrediction,
OverallReport,
Question,
SpeechResponse,
StartSessionResponse,
SubmitAnswerRequest,
SubmitAnswerResponse
+37
View File
@@ -23,19 +23,36 @@ export interface AnswerRecord {
}
export interface AppSettings {
"provider": string;
"hasApiKey": boolean;
"hasOpenAIKey": boolean;
"hasGeminiKey": boolean;
"demoMode": boolean;
"evaluationModel": string;
"transcribeModel": string;
"realtimeModel": string;
"speechModel": string;
"speechVoice": string;
"geminiEvaluationModels": string[] | null;
"geminiTranscriptionModels": string[] | null;
"geminiSpeechModels": string[] | null;
"geminiSpeechVoice": string;
}
export interface ConfigureRequest {
"provider": string;
"apiKey": string;
"geminiApiKey": string;
"demoMode": boolean;
"evaluationModel": string;
"transcribeModel": string;
"realtimeModel": string;
"speechModel": string;
"speechVoice": string;
"geminiEvaluationModels": string[] | null;
"geminiTranscriptionModels": string[] | null;
"geminiSpeechModels": string[] | null;
"geminiSpeechVoice": string;
}
export interface ExamConfig {
@@ -49,15 +66,30 @@ export interface ExamReport {
"config": ExamConfig;
"overall": OverallReport;
"answers": AnswerRecord[] | null;
"answeredCount": number;
"totalCount": number;
"partial": boolean;
"generatedAt": time$0.Time;
}
export interface GradePrediction {
"grade": string;
"probability": number;
"status": string;
"description": string;
}
export interface OverallReport {
"score": number;
"estimatedBand": string;
"summary": string;
"strengths": string[] | null;
"weaknesses": string[] | null;
"priorities": string[] | null;
"targetGrade": string;
"targetStatus": string;
"targetProbability": number;
"gradePredictions": GradePrediction[] | null;
}
export interface Question {
@@ -68,6 +100,11 @@ export interface Question {
"intent": string;
}
export interface SpeechResponse {
"audioBase64": string;
"mimeType": string;
}
export interface StartSessionResponse {
"sessionId": string;
"question": Question;
+399 -49
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { api } from "./api";
import type { AppSettings, Evaluation, ExamReport, Question } from "./types";
import type { AnswerRecord, AppSettings, Evaluation, ExamReport, Question } from "./types";
type Screen = "setup" | "exam" | "report";
@@ -16,13 +16,23 @@ const selectedDifficulty = ref("IM2");
const selectedTopics = ref<string[]>(["영화", "여행", "카페"]);
const settingsOpen = ref(false);
const settings = ref<AppSettings>({
provider: "openai",
hasApiKey: false,
hasOpenAIKey: false,
hasGeminiKey: false,
demoMode: true,
evaluationModel: "gpt-5-mini",
transcribeModel: "gpt-4o-transcribe",
realtimeModel: "gpt-realtime"
realtimeModel: "gpt-realtime",
speechModel: "gpt-4o-mini-tts",
speechVoice: "marin",
geminiEvaluationModels: ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
geminiTranscriptionModels: ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
geminiSpeechModels: ["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"],
geminiSpeechVoice: "Kore"
});
const apiKey = ref("");
const openAIAPIKey = ref("");
const geminiAPIKey = ref("");
const connectionMessage = ref("");
const busy = ref(false);
const error = ref("");
@@ -33,17 +43,50 @@ const totalCount = ref(15);
const completedCount = ref(0);
const transcript = ref("");
const latestEvaluation = ref<Evaluation | null>(null);
const answeredRecords = ref<AnswerRecord[]>([]);
const pendingNextQuestion = ref<Question | null>(null);
const report = ref<ExamReport | null>(null);
const recording = ref(false);
const hasRecording = ref(false);
const autoSubmitting = ref(false);
const speaking = ref(false);
const elapsed = ref(0);
let timer: number | undefined;
let recorder: MediaRecorder | null = null;
let stream: MediaStream | null = null;
let chunks: Blob[] = [];
let recordedBlob: Blob | null = null;
let stopRecordingPromise: Promise<Blob | null> | null = null;
let resolveStopRecording: ((blob: Blob | null) => void) | null = null;
let englishVoice: SpeechSynthesisVoice | null = null;
let questionAudio: HTMLAudioElement | null = null;
let questionAudioURL = "";
const progress = computed(() => Math.round((completedCount.value / totalCount.value) * 100));
const topicLabel = computed(() => selectedTopics.value.join(" · "));
const currentAverage = computed(() => {
if (!answeredRecords.value.length) return 0;
const total = answeredRecords.value.reduce((sum, answer) => sum + answer.evaluation.score, 0);
return Math.round(total / answeredRecords.value.length);
});
const canRestartRecording = computed(() =>
(recording.value || hasRecording.value) && elapsed.value <= 20 && !busy.value
);
const remainingSeconds = computed(() => Math.max(0, 120 - elapsed.value));
const viewedAnswer = computed(() =>
answeredRecords.value.find((answer) => answer.question.index === currentQuestion.value?.index) ?? null
);
const isReviewingAnswer = computed(() => viewedAnswer.value !== null);
const canGoPrevious = computed(() =>
Boolean(currentQuestion.value && currentQuestion.value.index > 1 &&
answeredRecords.value.some((answer) => answer.question.index === currentQuestion.value!.index - 1))
);
const canGoNext = computed(() => {
if (!currentQuestion.value) return false;
const nextIndex = currentQuestion.value.index + 1;
return answeredRecords.value.some((answer) => answer.question.index === nextIndex) ||
pendingNextQuestion.value?.index === nextIndex;
});
onMounted(async () => {
await loadEnglishVoice();
@@ -55,6 +98,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
if (recording.value && recorder?.state !== "inactive") recorder?.stop();
stopTracks();
if (timer) window.clearInterval(timer);
});
@@ -70,13 +114,22 @@ async function saveSettings() {
error.value = "";
try {
settings.value = await api.configure({
apiKey: apiKey.value,
provider: settings.value.provider,
apiKey: openAIAPIKey.value,
geminiApiKey: geminiAPIKey.value,
demoMode: settings.value.demoMode,
evaluationModel: settings.value.evaluationModel,
transcribeModel: settings.value.transcribeModel,
realtimeModel: settings.value.realtimeModel
realtimeModel: settings.value.realtimeModel,
speechModel: settings.value.speechModel,
speechVoice: settings.value.speechVoice,
geminiEvaluationModels: settings.value.geminiEvaluationModels,
geminiTranscriptionModels: settings.value.geminiTranscriptionModels,
geminiSpeechModels: settings.value.geminiSpeechModels,
geminiSpeechVoice: settings.value.geminiSpeechVoice
});
apiKey.value = "";
openAIAPIKey.value = "";
geminiAPIKey.value = "";
settingsOpen.value = false;
} catch (e) {
error.value = readableError(e);
@@ -88,13 +141,21 @@ async function saveSettings() {
async function testConnection() {
connectionMessage.value = "연결 확인 중…";
try {
if (apiKey.value) {
if (openAIAPIKey.value || geminiAPIKey.value) {
settings.value = await api.configure({
apiKey: apiKey.value,
provider: settings.value.provider,
apiKey: openAIAPIKey.value,
geminiApiKey: geminiAPIKey.value,
demoMode: false,
evaluationModel: settings.value.evaluationModel,
transcribeModel: settings.value.transcribeModel,
realtimeModel: settings.value.realtimeModel
realtimeModel: settings.value.realtimeModel,
speechModel: settings.value.speechModel,
speechVoice: settings.value.speechVoice,
geminiEvaluationModels: settings.value.geminiEvaluationModels,
geminiTranscriptionModels: settings.value.geminiTranscriptionModels,
geminiSpeechModels: settings.value.geminiSpeechModels,
geminiSpeechVoice: settings.value.geminiSpeechVoice
});
}
await api.testConnection();
@@ -121,6 +182,8 @@ async function startExam() {
currentQuestion.value = result.question;
totalCount.value = result.totalCount;
completedCount.value = 0;
answeredRecords.value = [];
pendingNextQuestion.value = null;
transcript.value = "";
latestEvaluation.value = null;
screen.value = "exam";
@@ -169,9 +232,32 @@ async function loadEnglishVoice(): Promise<SpeechSynthesisVoice | null> {
}
async function speakQuestion() {
if (!currentQuestion.value || !("speechSynthesis" in window)) return;
if (!currentQuestion.value) return;
stopQuestionAudio();
speechSynthesis.cancel();
if (settings.value.hasApiKey && !settings.value.demoMode) {
speaking.value = true;
try {
const result = await api.generateSpeech(currentQuestion.value.text);
const bytes = Uint8Array.from(atob(result.audioBase64), (char) => char.charCodeAt(0));
const blob = new Blob([bytes], { type: result.mimeType });
questionAudioURL = URL.createObjectURL(blob);
questionAudio = new Audio(questionAudioURL);
questionAudio.onended = () => {
stopQuestionAudio();
void startRecording();
};
questionAudio.onerror = stopQuestionAudio;
await questionAudio.play();
return;
} catch (e) {
error.value = `${readableError(e)} 시스템 영어 음성으로 재생합니다.`;
stopQuestionAudio();
}
}
if (!("speechSynthesis" in window)) return;
const voice = englishVoice ?? await loadEnglishVoice();
const naturalEnglish = currentQuestion.value.text
.replace(/\s+/g, " ")
@@ -183,14 +269,31 @@ async function speakQuestion() {
utterance.rate = 0.88;
utterance.pitch = 0.98;
utterance.volume = 1;
utterance.onend = () => {
speaking.value = false;
void startRecording();
};
utterance.onerror = () => { speaking.value = false; };
speaking.value = true;
speechSynthesis.speak(utterance);
}
async function toggleRecording() {
if (recording.value) {
recorder?.stop();
return;
function stopQuestionAudio() {
if (questionAudio) {
questionAudio.pause();
questionAudio.onended = null;
questionAudio.onerror = null;
questionAudio = null;
}
if (questionAudioURL) {
URL.revokeObjectURL(questionAudioURL);
questionAudioURL = "";
}
speaking.value = false;
}
async function startRecording() {
if (recording.value || hasRecording.value || busy.value || isReviewingAnswer.value || !currentQuestion.value) return;
error.value = "";
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -199,35 +302,93 @@ async function toggleRecording() {
: "audio/webm";
recorder = new MediaRecorder(stream, { mimeType: preferred });
chunks = [];
recordedBlob = null;
hasRecording.value = false;
stopRecordingPromise = new Promise<Blob | null>((resolve) => {
resolveStopRecording = resolve;
});
recorder.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
recorder.onstop = () => {
recordedBlob = chunks.length
? new Blob(chunks, { type: recorder?.mimeType || preferred })
: null;
hasRecording.value = Boolean(recordedBlob?.size);
recording.value = false;
if (timer) window.clearInterval(timer);
timer = undefined;
stopTracks();
resolveStopRecording?.(recordedBlob);
resolveStopRecording = null;
};
recorder.start(250);
elapsed.value = 0;
recording.value = true;
timer = window.setInterval(() => elapsed.value++, 1000);
timer = window.setInterval(() => {
elapsed.value += 1;
if (elapsed.value >= 120) {
window.clearInterval(timer);
timer = undefined;
void forceSubmitAtLimit();
}
}, 1000);
} catch {
error.value = "마이크 권한을 확인하세요. 텍스트 답변으로도 진행할 수 있습니다.";
}
}
async function stopRecording(): Promise<Blob | null> {
if (!recording.value || !recorder || recorder.state === "inactive") {
return recordedBlob;
}
const pending = stopRecordingPromise;
recorder.stop();
return pending ?? recordedBlob;
}
async function toggleRecording() {
if (recording.value) {
await stopRecording();
return;
}
if (hasRecording.value) return;
await startRecording();
}
async function restartRecording() {
if (!canRestartRecording.value) return;
await stopRecording();
chunks = [];
recordedBlob = null;
hasRecording.value = false;
elapsed.value = 0;
await startRecording();
}
async function forceSubmitAtLimit() {
if (autoSubmitting.value || busy.value || !recording.value) return;
autoSubmitting.value = true;
try {
await stopRecording();
await submitAnswer(true);
} finally {
autoSubmitting.value = false;
}
}
function stopTracks() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
}
async function submitAnswer() {
if (!currentQuestion.value || busy.value) return;
if (recording.value) recorder?.stop();
async function submitAnswer(forced = false) {
if (!currentQuestion.value || busy.value || isReviewingAnswer.value) return;
const blob = recording.value ? await stopRecording() : recordedBlob;
if (!forced && !transcript.value.trim() && !blob) return;
busy.value = true;
error.value = "";
try {
const blob = chunks.length ? new Blob(chunks, { type: recorder?.mimeType || "audio/webm" }) : null;
const audioBase64 = blob ? await blobToBase64(blob) : "";
const response = await api.submitAnswer({
sessionId: sessionId.value,
@@ -240,19 +401,71 @@ async function submitAnswer() {
transcript.value = response.transcript;
latestEvaluation.value = response.evaluation;
completedCount.value = response.progress;
answeredRecords.value.push({
question: currentQuestion.value,
transcript: response.transcript,
durationSec: elapsed.value,
evaluation: response.evaluation
});
pendingNextQuestion.value = response.next || null;
chunks = [];
if (response.completed) {
report.value = await api.getReport(sessionId.value);
screen.value = "report";
return;
}
window.setTimeout(() => {
currentQuestion.value = response.next || null;
transcript.value = "";
latestEvaluation.value = null;
elapsed.value = 0;
window.setTimeout(speakQuestion, 350);
}, 900);
recordedBlob = null;
hasRecording.value = false;
} catch (e) {
error.value = readableError(e);
} finally {
busy.value = false;
}
}
function showQuestion(question: Question, answer?: AnswerRecord) {
stopQuestionAudio();
speechSynthesis?.cancel();
currentQuestion.value = question;
transcript.value = answer?.transcript ?? "";
latestEvaluation.value = answer?.evaluation ?? null;
elapsed.value = answer?.durationSec ?? 0;
chunks = [];
recordedBlob = null;
hasRecording.value = false;
}
function goToPreviousQuestion() {
if (!currentQuestion.value) return;
const answer = answeredRecords.value.find(
(item) => item.question.index === currentQuestion.value!.index - 1
);
if (answer) showQuestion(answer.question, answer);
}
function goToNextQuestion() {
if (!currentQuestion.value) return;
const nextIndex = currentQuestion.value.index + 1;
const answered = answeredRecords.value.find((item) => item.question.index === nextIndex);
if (answered) {
showQuestion(answered.question, answered);
return;
}
if (pendingNextQuestion.value?.index === nextIndex) {
const next = pendingNextQuestion.value;
showQuestion(next);
window.setTimeout(speakQuestion, 350);
}
}
function goToAnsweredQuestion(answer: AnswerRecord) {
showQuestion(answer.question, answer);
}
async function finalizeExam() {
if (!sessionId.value || completedCount.value === 0 || busy.value) return;
busy.value = true;
error.value = "";
try {
stopQuestionAudio();
speechSynthesis?.cancel();
report.value = await api.finalizeSession(sessionId.value);
screen.value = "report";
} catch (e) {
error.value = readableError(e);
} finally {
@@ -261,13 +474,18 @@ async function submitAnswer() {
}
function resetExam() {
stopQuestionAudio();
speechSynthesis?.cancel();
screen.value = "setup";
report.value = null;
currentQuestion.value = null;
latestEvaluation.value = null;
answeredRecords.value = [];
pendingNextQuestion.value = null;
transcript.value = "";
completedCount.value = 0;
recordedBlob = null;
hasRecording.value = false;
}
function printReport() {
@@ -287,6 +505,16 @@ function readableError(value: unknown) {
if (value instanceof Error) return value.message;
return String(value).replace(/^.*?: /, "");
}
function updateGeminiModels(
field: "geminiEvaluationModels" | "geminiTranscriptionModels" | "geminiSpeechModels",
event: Event
) {
settings.value[field] = (event.target as HTMLInputElement).value
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
}
</script>
<template>
@@ -298,7 +526,7 @@ function readableError(value: unknown) {
</button>
<div class="top-actions">
<span class="connection-pill" :class="{ live: settings.hasApiKey && !settings.demoMode }">
<i></i>{{ settings.demoMode ? "DEMO MODE" : "OPENAI CONNECTED" }}
<i></i>{{ settings.demoMode ? "DEMO MODE" : `${settings.provider.toUpperCase()} CONNECTED` }}
</span>
<button class="icon-button" aria-label="설정" @click="settingsOpen = true"></button>
</div>
@@ -347,37 +575,101 @@ function readableError(value: unknown) {
<div class="progress-ring" :style="{ '--progress': `${progress * 3.6}deg` }"><span><strong>{{ completedCount }}</strong>/ {{ totalCount }}</span></div>
<div class="progress-meta"><span>PROGRESS</span><b>{{ progress }}%</b></div>
<div class="progress-bar"><i :style="{ width: `${progress}%` }"></i></div>
<div v-if="answeredRecords.length" class="current-average">
<div><small>CURRENT AVERAGE</small><strong>{{ currentAverage }}</strong><span>/ 100</span></div>
<p>{{ completedCount }} 문항 기준 평균</p>
</div>
<div v-if="answeredRecords.length" class="question-history">
<small>ANSWER HISTORY</small>
<div>
<button
v-for="answer in answeredRecords"
:key="answer.question.index"
:class="{ active: currentQuestion?.index === answer.question.index }"
@click="goToAnsweredQuestion(answer)"
>
<span class="history-number">Q{{ answer.question.index.toString().padStart(2, "0") }}</span>
<span class="history-category">{{ answer.question.category }}</span>
<strong>{{ answer.evaluation.score }}</strong>
</button>
</div>
</div>
<div class="exam-tip"><small>COACH'S NOTE</small><p>완벽한 문법보다 구체적인 경험과 자연스러운 이야기 흐름에 집중하세요.</p></div>
<button class="finish-early" :disabled="busy || completedCount === 0" @click="finalizeExam">
{{ completedCount === totalCount ? "최종 결과 보기" : "현재까지 최종 제출" }}
</button>
</aside>
<section class="conversation">
<div class="question-navigation">
<button class="secondary nav-button" :disabled="!canGoPrevious" @click="goToPreviousQuestion">← 이전 문제</button>
<span v-if="isReviewingAnswer">제출 완료 · 평가 다시 보기</span>
<span v-else>답변 대기 중</span>
<button class="secondary nav-button" :disabled="!canGoNext" @click="goToNextQuestion">다음 문제 →</button>
</div>
<div class="question-meta">
<span>QUESTION {{ currentQuestion?.index?.toString().padStart(2, "0") }}</span>
<b>{{ currentQuestion?.category }}</b>
<span>{{ currentQuestion?.topic }}</span>
</div>
<div class="question-card">
<button class="speaker" @click="speakQuestion">◖))</button>
<div><small>INTERVIEWER</small><h1>{{ currentQuestion?.text }}</h1><p>{{ currentQuestion?.intent }}</p></div>
<button class="speaker" :class="{ active: speaking }" :disabled="speaking || recording" @click="speakQuestion">{{ speaking ? "•••" : "◖))" }}</button>
<div><small>AI INTERVIEWER · AI-GENERATED VOICE</small><h1>{{ currentQuestion?.text }}</h1><p>{{ currentQuestion?.intent }}</p></div>
</div>
<div class="answer-area">
<div v-if="!isReviewingAnswer" class="answer-area">
<div class="record-row">
<button class="record-button" :class="{ active: recording }" @click="toggleRecording"><i></i>{{ recording ? "녹음 중지" : "답변 녹음" }}</button>
<span class="timer">{{ Math.floor(elapsed / 60).toString().padStart(2, "0") }}:{{ (elapsed % 60).toString().padStart(2, "0") }}</span>
<button class="record-button" :class="{ active: recording }" :disabled="hasRecording && !recording" @click="toggleRecording">
<i></i>{{ recording ? "녹음 중지" : hasRecording ? "녹음 완료" : "답변 녹음" }}
</button>
<button v-if="canRestartRecording" class="restart-button" @click="restartRecording">↻ 처음부터 다시</button>
<span class="timer" :class="{ warning: remainingSeconds <= 20 }">
{{ Math.floor(elapsed / 60).toString().padStart(2, "0") }}:{{ (elapsed % 60).toString().padStart(2, "0") }}
<small>/ 02:00</small>
</span>
<span class="audio-bars" :class="{ active: recording }"><i v-for="n in 18" :key="n"></i></span>
</div>
<div class="recording-guide">
<span v-if="speaking">질문 재생이 끝나면 녹음이 자동 시작됩니다.</span>
<span v-else-if="recording && elapsed <= 20">자동 녹음 중 · {{ 20 - elapsed }}초 동안 다시 시작 가능</span>
<span v-else-if="recording">자동 녹음 중 · {{ remainingSeconds }}초 후 자동 제출</span>
<span v-else-if="hasRecording">녹음 완료 · 답변을 제출하거나 다시 녹음할 수 있습니다.</span>
<span v-else>질문을 다시 들으면 종료 후 녹음이 자동 시작됩니다.</span>
</div>
<label for="transcript">답변 스크립트 <small>녹음만 제출하면 AI가 자동 전사합니다. 직접 입력하거나 수정할 수도 있습니다.</small></label>
<textarea id="transcript" v-model="transcript" placeholder="Start speaking, or type your answer here…"></textarea>
<div class="submit-row">
<span>{{ transcript.trim().split(/\s+/).filter(Boolean).length }} words</span>
<button class="primary" :disabled="busy || (!transcript.trim() && !chunks.length)" @click="submitAnswer">{{ busy ? "AI 분석 중…" : "답변 제출" }} <span>→</span></button>
<button class="primary" :disabled="busy || (!transcript.trim() && !hasRecording && !recording)" @click="submitAnswer(false)">
{{ autoSubmitting ? "시간 종료 · 자동 제출 중…" : busy ? "AI 분석 중…" : "답변 제출" }} <span>→</span>
</button>
</div>
</div>
<div v-if="latestEvaluation" class="instant-feedback">
<div class="score">{{ latestEvaluation.score }}</div>
<div><small>INSTANT FEEDBACK</small><p>{{ latestEvaluation.feedback }}</p></div>
<div v-else class="submitted-answer">
<small>YOUR ANSWER · {{ viewedAnswer?.durationSec }} SEC</small>
<p>{{ viewedAnswer?.transcript }}</p>
</div>
<div v-if="latestEvaluation" class="evaluation-panel">
<header>
<div class="score">{{ latestEvaluation.score }}</div>
<div><small>AI ANALYSIS COMPLETE</small><h2>답변 분석 결과</h2><p>{{ latestEvaluation.feedback }}</p></div>
</header>
<div class="keyword-row">
<span v-for="keyword in latestEvaluation.keywords" :key="keyword">{{ keyword }}</span>
</div>
<div class="evaluation-grid">
<article><small>잘한 점</small><ul><li v-for="item in latestEvaluation.strengths" :key="item">{{ item }}</li></ul></article>
<article><small>개선할 점</small><ul><li v-for="item in latestEvaluation.improvements" :key="item">{{ item }}</li></ul></article>
</div>
<footer>
<button class="secondary" :disabled="!canGoPrevious" @click="goToPreviousQuestion">← 이전 문제 보기</button>
<button v-if="canGoNext" class="primary" @click="goToNextQuestion">다음 문제로 <span>→</span></button>
<button v-else class="primary" :disabled="busy" @click="finalizeExam">
{{ busy ? "총평 생성 중…" : "최종 결과 보기" }} <span>→</span>
</button>
</footer>
</div>
<p v-if="error" class="error">{{ error }}</p>
</section>
@@ -388,18 +680,39 @@ function readableError(value: unknown) {
<p class="eyebrow">SESSION COMPLETE</p>
<h1>Speaking<br />Performance<br /><em>Report</em></h1>
<p>{{ new Date(report.generatedAt).toLocaleDateString("ko-KR") }} · {{ report.config.difficulty }} · {{ report.config.topics.join(", ") }}</p>
<p class="completion-label">{{ report.partial ? `조기 제출 · ${report.answeredCount}/${report.totalCount} 문항 완료` : `전체 ${report.totalCount} 문항 완료` }}</p>
<div class="overall-score">
<div><strong>{{ report.overall.score }}</strong><span>/ 100</span></div>
<aside><small>ESTIMATED BAND</small><b>{{ report.overall.estimatedBand }}</b></aside>
</div>
<p class="report-summary">{{ report.overall.summary }}</p>
<div class="report-actions"><button class="secondary" @click="printReport">보고서 인쇄</button><button class="primary" @click="resetExam">새 연습 시작 →</button></div>
<div class="target-result">
<small>TARGET GRADE · {{ report.overall.targetGrade }}</small>
<strong>{{ report.overall.targetStatus }}</strong>
<span>{{ report.overall.targetProbability }}% estimated likelihood</span>
</div>
<div class="report-actions"><button class="secondary" @click="printReport">Print / Save as PDF</button><button class="primary" @click="resetExam">Start New Session →</button></div>
</section>
<section class="report-content">
<div class="overview-grid">
<article><small>STRENGTHS</small><ul><li v-for="item in report.overall.strengths" :key="item">{{ item }}</li></ul></article>
<article class="accent"><small>NEXT PRIORITIES</small><ul><li v-for="item in report.overall.priorities" :key="item">{{ item }}</li></ul></article>
<article><small>WEAKNESSES</small><ul><li v-for="item in report.overall.weaknesses" :key="item">{{ item }}</li></ul></article>
<article class="accent"><small>PRIORITY IMPROVEMENTS</small><ul><li v-for="item in report.overall.priorities" :key="item">{{ item }}</li></ul></article>
</div>
<section class="grade-predictions">
<header><div><small>OPIc GRADE FORECAST</small><h2>Estimated grade attainment</h2></div><p>Practice estimate, not an official OPIc score.</p></header>
<div class="grade-table">
<article v-for="grade in report.overall.gradePredictions" :key="grade.grade" :class="grade.status.toLowerCase()">
<strong>{{ grade.grade }}</strong>
<div><span>{{ grade.status }}</span><p>{{ grade.description }}</p></div>
<aside><b>{{ grade.probability }}%</b><i><em :style="{ width: `${grade.probability}%` }"></em></i></aside>
</article>
</div>
</section>
<div class="section-heading">
<small>QUESTION-BY-QUESTION REVIEW</small>
<h2>Detailed response analysis</h2>
</div>
<div class="answer-list">
<article v-for="answer in report.answers" :key="answer.question.index" class="answer-report">
@@ -407,8 +720,8 @@ function readableError(value: unknown) {
<blockquote>{{ answer.transcript }}</blockquote>
<div class="keyword-row"><span v-for="keyword in answer.evaluation.keywords" :key="keyword">{{ keyword }}</span></div>
<div class="feedback-grid">
<div><small>잘한 점</small><p>{{ answer.evaluation.strengths.join(" ") }}</p></div>
<div><small>개선점</small><p>{{ answer.evaluation.improvements.join(" ") }}</p></div>
<div><small>STRENGTHS</small><p>{{ answer.evaluation.strengths.join(" ") }}</p></div>
<div><small>AREAS TO IMPROVE</small><p>{{ answer.evaluation.improvements.join(" ") }}</p></div>
</div>
</article>
</div>
@@ -419,14 +732,51 @@ function readableError(value: unknown) {
<section class="settings-modal">
<button class="modal-close" @click="settingsOpen = false">×</button>
<p class="eyebrow">AI CONNECTION</p>
<h2>OpenAI 연결 설정</h2>
<p class="modal-copy">ChatGPT 웹 로그인은 서드파티 데스크톱 앱의 API 인증으로 사용할 수 습니다. OpenAI Platform API 키를 사용하세요. 키는 현재 앱 프로세스 메모리에만 보관됩니다.</p>
<label>API key<input v-model="apiKey" type="password" placeholder="sk-" autocomplete="off" /></label>
<h2>AI 연결 설정</h2>
<p class="modal-copy">OpenAI 또는 Google Gemini API를 선택할 수 습니다. API 키는 현재 앱 프로세스 메모리에만 보관됩니다.</p>
<div class="provider-tabs">
<button :class="{ selected: settings.provider === 'openai' }" @click="settings.provider = 'openai'">OpenAI</button>
<button :class="{ selected: settings.provider === 'gemini' }" @click="settings.provider = 'gemini'">Google Gemini</button>
</div>
<label v-if="settings.provider === 'openai'">OpenAI API key<input v-model="openAIAPIKey" type="password" placeholder="sk-…" autocomplete="off" /></label>
<label v-else>Gemini API key<input v-model="geminiAPIKey" type="password" placeholder="AIza…" autocomplete="off" /></label>
<label class="switch-row"><span><b>데모 모드</b><small>API 비용 없이 전체 흐름을 시험합니다.</small></span><input v-model="settings.demoMode" type="checkbox" /></label>
<div class="model-grid">
<div v-if="settings.provider === 'openai'" class="model-grid">
<label>평가 모델<input v-model="settings.evaluationModel" /></label>
<label>전사 모델<input v-model="settings.transcribeModel" /></label>
<label>Realtime 모델<input v-model="settings.realtimeModel" /></label>
<label>TTS 모델<input v-model="settings.speechModel" /></label>
<label>TTS 음성
<select v-model="settings.speechVoice">
<option value="marin">Marin — 자연스럽고 선명함</option>
<option value="cedar">Cedar — 차분하고 안정적</option>
<option value="coral">Coral — 밝고 친근함</option>
<option value="sage">Sage — 중립적</option>
<option value="onyx">Onyx — 낮고 묵직함</option>
</select>
</label>
</div>
<div v-else class="gemini-models">
<p class="fallback-note">모델을 쉼표 또는 줄바꿈으로 입력하세요. 위에서부터 시도하고 실패하면 다음 모델로 자동 전환합니다.</p>
<label>평가 모델 우선순위
<textarea class="model-list" :value="settings.geminiEvaluationModels.join('\n')" @input="updateGeminiModels('geminiEvaluationModels', $event)"></textarea>
</label>
<label>음성 전사 모델 우선순위
<textarea class="model-list" :value="settings.geminiTranscriptionModels.join('\n')" @input="updateGeminiModels('geminiTranscriptionModels', $event)"></textarea>
</label>
<label>TTS 모델 우선순위
<textarea class="model-list" :value="settings.geminiSpeechModels.join('\n')" @input="updateGeminiModels('geminiSpeechModels', $event)"></textarea>
</label>
<label>Gemini TTS 음성
<select v-model="settings.geminiSpeechVoice">
<option value="Kore">Kore — 단정하고 힘 있음</option>
<option value="Iapetus">Iapetus — 선명함</option>
<option value="Schedar">Schedar — 균형 잡힘</option>
<option value="Gacrux">Gacrux — 성숙함</option>
<option value="Charon">Charon — 설명형</option>
<option value="Aoede">Aoede — 부드러움</option>
</select>
</label>
</div>
<p v-if="connectionMessage" class="connection-message">{{ connectionMessage }}</p>
<p v-if="error" class="error">{{ error }}</p>
+4
View File
@@ -6,6 +6,8 @@ export const api = {
configure: (settings: Record<string, unknown>) =>
AppService.Configure(settings as never) as Promise<AppSettings>,
testConnection: () => AppService.TestConnection() as Promise<void>,
generateSpeech: (text: string) =>
AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>,
startSession: (config: Record<string, unknown>) =>
AppService.StartSession(config as never) as Promise<{
sessionId: string;
@@ -20,5 +22,7 @@ export const api = {
completed: boolean;
progress: number;
}>,
finalizeSession: (sessionId: string) =>
AppService.FinalizeSession(sessionId) as Promise<ExamReport>,
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport>
};
+84 -4
View File
@@ -83,11 +83,35 @@ button { color: inherit; }
.exam-tip { margin-top: 60px; padding: 18px; border: 1px solid #ffffff18; background: #ffffff08; }
.exam-tip small { color: var(--lime); font-size: 8px; letter-spacing: 1.4px; }
.exam-tip p { margin: 9px 0 0; color: #b8c6bd; font-size: 11px; line-height: 1.7; }
.current-average { margin-top: 24px; padding: 16px 17px; border: 1px solid #ffffff1f; background: #ffffff08; }
.current-average > div { display: flex; align-items: baseline; }
.current-average small { margin-right: auto; color: #91a39a; font-size: 8px; letter-spacing: 1.3px; }
.current-average strong { color: var(--lime); font: 700 30px Manrope; }
.current-average span { margin-left: 3px; color: #8fa097; font-size: 9px; }
.current-average p { margin: 4px 0 0; color: #a9b7ae; font-size: 9px; }
.question-history { margin-top: 22px; }
.question-history > small { color: #8fa298; font-size: 8px; letter-spacing: 1.4px; }
.question-history > div { display: grid; gap: 6px; margin-top: 10px; max-height: 215px; overflow-y: auto; padding-right: 3px; }
.question-history button { min-height: 43px; padding: 7px 9px; display: grid; grid-template-columns: 30px 1fr 30px; align-items: center; gap: 7px; border: 1px solid #ffffff20; color: #dce5df; background: #ffffff08; cursor: pointer; text-align: left; }
.question-history .history-number { color: #a4b4aa; font-size: 8px; font-weight: 700; }
.question-history .history-category { overflow: hidden; color: #dce5df; font-size: 9px; white-space: nowrap; text-overflow: ellipsis; }
.question-history button strong { color: var(--lime); font: 700 13px Manrope; text-align: right; }
.question-history button.active { border-color: var(--lime); color: var(--green); background: var(--lime); }
.question-history button.active span, .question-history button.active strong { color: var(--green); }
.finish-early { width: 100%; min-height: 42px; margin-top: 16px; border: 1px solid #ffffff40; color: white; background: transparent; cursor: pointer; font-size: 10px; font-weight: 700; }
.finish-early:hover:not(:disabled) { border-color: var(--lime); color: var(--lime); }
.finish-early:disabled { opacity: .35; cursor: not-allowed; }
.conversation { padding: 5vh 7vw 8vh; background: var(--paper); }
.question-navigation { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }
.question-navigation > span { color: #7c8880; font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
.nav-button { min-height: 35px; padding: 0 13px; }
.nav-button:disabled { opacity: .3; cursor: not-allowed; }
.question-meta { display: flex; align-items: center; gap: 13px; color: #8a948d; font-size: 9px; letter-spacing: 1.3px; }
.question-meta b { padding: 6px 9px; color: var(--green); background: #e9f0e9; letter-spacing: 0; }
.question-card { display: grid; grid-template-columns: 58px 1fr; gap: 22px; margin-top: 30px; padding-bottom: 38px; border-bottom: 1px solid var(--line); }
.speaker { width: 54px; height: 54px; border-radius: 50%; border: 0; color: var(--green); background: var(--lime); cursor: pointer; font-weight: 800; }
.speaker.active { animation: speaker-pulse 1.2s ease-in-out infinite; cursor: wait; }
@keyframes speaker-pulse { 50% { transform: scale(.94); box-shadow: 0 0 0 10px #c9f36a33; } }
.question-card small, .instant-feedback small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.7px; }
.question-card h1 { max-width: 770px; margin: 9px 0 12px; font: 500 clamp(26px, 3vw, 42px)/1.22 Manrope; letter-spacing: -1.8px; }
.question-card p { color: var(--muted); font-size: 11px; }
@@ -96,7 +120,12 @@ button { color: inherit; }
.record-button { height: 40px; padding: 0 15px; display: flex; align-items: center; gap: 8px; border: 1px solid #dfd4ce; background: #fff8f4; color: #a7482a; cursor: pointer; font-size: 11px; font-weight: 700; }
.record-button i { width: 9px; height: 9px; border-radius: 50%; background: var(--orange); }
.record-button.active i { animation: pulse 1s infinite; }
.record-button:disabled { opacity: .55; cursor: default; }
.restart-button { height: 40px; padding: 0 12px; border: 1px solid #b9c9bd; color: var(--green); background: #edf5e8; cursor: pointer; font-size: 10px; font-weight: 700; }
.timer { font: 600 13px Manrope; font-variant-numeric: tabular-nums; }
.timer small { color: #9ba39e; font-size: 8px; }
.timer.warning { color: var(--orange); }
.recording-guide { margin-top: 9px; color: #7b8780; font-size: 9px; }
.audio-bars { height: 26px; flex: 1; display: flex; align-items: center; gap: 3px; overflow: hidden; }
.audio-bars i { width: 3px; height: 4px; background: #d4d9d2; }
.audio-bars.active i { background: #7fa18b; animation: wave .7s ease-in-out infinite alternate; }
@@ -111,6 +140,22 @@ textarea:focus, input:focus { border-color: #7e9b88; box-shadow: 0 0 0 3px #7e9b
.instant-feedback { margin-top: 18px; padding: 15px; display: flex; gap: 15px; align-items: center; background: #edf6d9; border-left: 3px solid #9dc74b; }
.instant-feedback .score { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--green); font: 700 16px Manrope; }
.instant-feedback p { margin: 4px 0 0; font-size: 11px; }
.submitted-answer { margin-top: 28px; padding: 20px; border: 1px solid var(--line); background: #f2f3ee; }
.submitted-answer small { color: #89938c; font-size: 8px; font-weight: 700; letter-spacing: 1.4px; }
.submitted-answer p { margin: 10px 0 0; color: #48574e; font-size: 12px; line-height: 1.8; white-space: pre-wrap; }
.evaluation-panel { margin-top: 22px; padding: 25px; border: 1px solid #cfdbc2; background: #f6faec; }
.evaluation-panel header { display: grid; grid-template-columns: 64px 1fr; gap: 18px; align-items: center; }
.evaluation-panel .score { width: 62px; height: 62px; display: grid; place-items: center; border-radius: 50%; color: var(--lime); background: var(--green); font: 700 22px Manrope; }
.evaluation-panel header small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.6px; }
.evaluation-panel h2 { margin: 4px 0; font: 700 20px Manrope; }
.evaluation-panel header p { margin: 0; color: #5d6a61; font-size: 11px; line-height: 1.65; }
.evaluation-panel .keyword-row { margin-top: 18px; }
.evaluation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 15px; }
.evaluation-grid article { padding: 16px; border: 1px solid #dce5d2; background: white; }
.evaluation-grid small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.3px; }
.evaluation-grid ul { margin: 10px 0 0; padding-left: 16px; color: #536158; font-size: 10px; line-height: 1.75; }
.evaluation-panel footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; padding-top: 17px; border-top: 1px solid #dce5d2; }
.completion-label { display: inline-block; padding: 6px 9px; color: var(--lime) !important; border: 1px solid #ffffff25; font-weight: 700; letter-spacing: .5px; }
.report-layout { display: grid; grid-template-columns: 38% 62%; min-height: calc(100vh - 76px); }
.report-cover { position: sticky; top: 0; height: calc(100vh - 76px); padding: 7vh 4vw; color: white; background: var(--green); }
.report-cover h1 { font-size: clamp(43px, 4.5vw, 68px); }
@@ -122,15 +167,36 @@ textarea:focus, input:focus { border-color: #7e9b88; box-shadow: 0 0 0 3px #7e9b
.overall-score small { display: block; color: #8ea096; font-size: 8px; letter-spacing: 1.5px; }
.overall-score b { display: block; margin-top: 7px; font-size: 16px; }
.report-summary { color: #b9c6bd; font-size: 12px; line-height: 1.7; }
.target-result { margin-top: 18px; padding: 15px 17px; border: 1px solid #ffffff22; background: #ffffff08; }
.target-result small { display: block; color: #91a39a; font-size: 8px; letter-spacing: 1.4px; }
.target-result strong { display: inline-block; margin-top: 7px; color: var(--lime); font: 700 19px Manrope; }
.target-result span { margin-left: 10px; color: #b6c3bb; font-size: 9px; }
.report-actions { display: flex; gap: 10px; margin-top: 25px; }
.report-cover .secondary { border-color: #ffffff55; color: white; }
.report-cover .primary { color: var(--green); background: var(--lime); border-color: var(--lime); }
.report-content { padding: 5vh 5vw; background: var(--paper); }
.overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.overview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.overview-grid article { padding: 25px; border: 1px solid var(--line); background: white; }
.overview-grid article.accent { background: #eef4dd; }
.overview-grid small, .answer-report small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.4px; }
.overview-grid ul { margin: 17px 0 0; padding-left: 18px; font-size: 12px; line-height: 2; }
.grade-predictions { margin-top: 32px; padding-top: 28px; border-top: 1px solid var(--line); }
.grade-predictions > header { display: flex; justify-content: space-between; align-items: end; gap: 20px; }
.grade-predictions header small, .section-heading small { color: var(--orange); font-size: 8px; font-weight: 700; letter-spacing: 1.5px; }
.grade-predictions h2, .section-heading h2 { margin: 6px 0 0; font: 700 22px Manrope; }
.grade-predictions > header p { max-width: 230px; margin: 0; color: var(--muted); font-size: 9px; text-align: right; }
.grade-table { margin-top: 18px; border-top: 1px solid var(--line); }
.grade-table article { min-height: 67px; display: grid; grid-template-columns: 50px 1fr 120px; gap: 14px; align-items: center; padding: 11px 8px; border-bottom: 1px solid var(--line); }
.grade-table article > strong { font: 700 17px Manrope; }
.grade-table article div span { display: inline-block; padding: 3px 7px; border-radius: 99px; font-size: 8px; font-weight: 700; text-transform: uppercase; }
.grade-table article.likely div span { color: #285b3e; background: #ddf0d8; }
.grade-table article.possible div span { color: #79591e; background: #f5e9c9; }
.grade-table article.unlikely div span { color: #8b4935; background: #f7ded5; }
.grade-table article div p { margin: 5px 0 0; color: #657168; font-size: 9px; }
.grade-table aside b { display: block; font: 700 12px Manrope; text-align: right; }
.grade-table aside i { display: block; height: 4px; margin-top: 6px; background: #e2e5de; }
.grade-table aside em { display: block; height: 100%; background: var(--green); }
.section-heading { margin-top: 38px; padding-top: 28px; border-top: 1px solid var(--line); }
.answer-list { margin-top: 32px; }
.answer-report { padding: 28px 0; border-top: 1px solid var(--line); }
.answer-report header { display: grid; grid-template-columns: 42px 1fr 55px; gap: 15px; align-items: start; }
@@ -146,9 +212,16 @@ blockquote { margin: 18px 0; padding: 16px 18px; color: #526057; background: #f1
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 30px; background: #102019aa; backdrop-filter: blur(6px); }
.settings-modal { position: relative; width: min(580px, 90vw); max-height: 90vh; overflow: auto; padding: 38px; background: var(--paper); box-shadow: 0 25px 80px #07140d66; }
.settings-modal h2 { margin: -15px 0 12px; font: 700 28px Manrope; }
.provider-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 22px 0 8px; }
.provider-tabs button { height: 42px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 11px; font-weight: 700; }
.provider-tabs button.selected { color: white; border-color: var(--green); background: var(--green); box-shadow: inset 0 -3px var(--lime); }
.modal-copy { color: var(--muted); font-size: 11px; line-height: 1.7; }
.modal-close { position: absolute; top: 14px; right: 18px; border: 0; background: none; font-size: 25px; cursor: pointer; }
.settings-modal input:not([type="checkbox"]) { width: 100%; height: 42px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--line); outline: none; background: white; }
.settings-modal select { width: 100%; height: 42px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--line); outline: none; background: white; }
.gemini-models { margin-top: 18px; }
.fallback-note { padding: 11px 13px; color: #59665e; background: #eef4dd; font-size: 10px; line-height: 1.6; }
.settings-modal .model-list { height: 82px; margin-top: 7px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.55; }
.switch-row { display: flex !important; justify-content: space-between; align-items: center; padding: 14px; background: #eef1ea; }
.switch-row b, .switch-row small { display: block; }
.switch-row small { margin-top: 4px; color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
@@ -166,11 +239,18 @@ blockquote { margin: 18px 0; padding: 16px 18px; color: #526057; background: #f1
}
@media print {
@page { size: A4; margin: 13mm; }
html, body { background: white; }
.topbar, .report-actions { display: none; }
.report-layout { display: block; }
.report-cover { position: static; height: auto; color: #14251c; background: white; }
.report-cover { position: static; height: auto; min-height: 255mm; padding: 18mm 12mm; color: #14251c; background: white; break-after: page; }
.report-cover h1 em, .overall-score strong { color: #14251c; }
.report-content { padding: 20px; }
.report-cover > p:not(.eyebrow):not(.report-summary), .report-summary, .target-result span { color: #526057; }
.overall-score, .target-result { border-color: #ccd2cc; }
.target-result strong { color: #14251c; }
.report-content { padding: 0; background: white; }
.overview-grid, .grade-predictions { break-inside: avoid; }
.overview-grid article { padding: 14px; }
.grade-table article { min-height: 56px; }
.answer-report { break-inside: avoid; }
}
+22 -1
View File
@@ -1,9 +1,18 @@
export interface AppSettings {
provider: "openai" | "gemini";
hasApiKey: boolean;
hasOpenAIKey: boolean;
hasGeminiKey: boolean;
demoMode: boolean;
evaluationModel: string;
transcribeModel: string;
realtimeModel: string;
speechModel: string;
speechVoice: string;
geminiEvaluationModels: string[];
geminiTranscriptionModels: string[];
geminiSpeechModels: string[];
geminiSpeechVoice: string;
}
export interface Question {
@@ -37,9 +46,21 @@ export interface ExamReport {
estimatedBand: string;
summary: string;
strengths: string[];
weaknesses: string[];
priorities: string[];
targetGrade: string;
targetStatus: string;
targetProbability: number;
gradePredictions: {
grade: string;
probability: number;
status: string;
description: string;
}[];
};
answers: AnswerRecord[];
answeredCount: number;
totalCount: number;
partial: boolean;
generatedAt: string;
}