Initial commit

This commit is contained in:
arhat0307
2026-06-26 00:06:07 +09:00
commit 75f9c5d7d4
108 changed files with 10256 additions and 0 deletions
@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+34
View File
@@ -0,0 +1,34 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function Configure(request: $models.ConfigureRequest): $CancellablePromise<$models.AppSettings> {
return $Call.ByID(1065098514, request);
}
export function GetReport(sessionID: string): $CancellablePromise<$models.ExamReport> {
return $Call.ByID(704064164, sessionID);
}
export function GetSettings(): $CancellablePromise<$models.AppSettings> {
return $Call.ByID(3018893939);
}
export function StartSession(config: $models.ExamConfig): $CancellablePromise<$models.StartSessionResponse> {
return $Call.ByID(13616470, config);
}
export function SubmitAnswer(request: $models.SubmitAnswerRequest): $CancellablePromise<$models.SubmitAnswerResponse> {
return $Call.ByID(665225252, request);
}
export function TestConnection(): $CancellablePromise<void> {
return $Call.ByID(4037176558);
}
+21
View File
@@ -0,0 +1,21 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as AppService from "./appservice.js";
export {
AppService
};
export type {
AnswerEvaluation,
AnswerRecord,
AppSettings,
ConfigureRequest,
ExamConfig,
ExamReport,
OverallReport,
Question,
StartSessionResponse,
SubmitAnswerRequest,
SubmitAnswerResponse
} from "./models.js";
+92
View File
@@ -0,0 +1,92 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../time/models.js";
export interface AnswerEvaluation {
"score": number;
"keywords": string[] | null;
"strengths": string[] | null;
"improvements": string[] | null;
"feedback": string;
}
export interface AnswerRecord {
"question": Question;
"transcript": string;
"durationSec": number;
"evaluation": AnswerEvaluation;
"answeredAt": time$0.Time;
"audioPresent": boolean;
}
export interface AppSettings {
"hasApiKey": boolean;
"demoMode": boolean;
"evaluationModel": string;
"transcribeModel": string;
"realtimeModel": string;
}
export interface ConfigureRequest {
"apiKey": string;
"demoMode": boolean;
"evaluationModel": string;
"transcribeModel": string;
"realtimeModel": string;
}
export interface ExamConfig {
"difficulty": string;
"topics": string[] | null;
"language": string;
}
export interface ExamReport {
"sessionId": string;
"config": ExamConfig;
"overall": OverallReport;
"answers": AnswerRecord[] | null;
"generatedAt": time$0.Time;
}
export interface OverallReport {
"score": number;
"estimatedBand": string;
"summary": string;
"strengths": string[] | null;
"priorities": string[] | null;
}
export interface Question {
"index": number;
"category": string;
"topic": string;
"text": string;
"intent": string;
}
export interface StartSessionResponse {
"sessionId": string;
"question": Question;
"totalCount": number;
}
export interface SubmitAnswerRequest {
"sessionId": string;
"questionIdx": number;
"audioBase64": string;
"audioMime": string;
"transcript": string;
"durationSec": number;
}
export interface SubmitAnswerResponse {
"transcript": string;
"evaluation": AnswerEvaluation;
"next"?: Question | null;
"completed": boolean;
"progress": number;
}
+6
View File
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export type {
Time
} from "./models.js";
+47
View File
@@ -0,0 +1,47 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* A Time represents an instant in time with nanosecond precision.
*
* Programs using times should typically store and pass them as values,
* not pointers. That is, time variables and struct fields should be of
* type [time.Time], not *time.Time.
*
* A Time value can be used by multiple goroutines simultaneously except
* that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
* [Time.UnmarshalText] are not concurrency-safe.
*
* Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
* The [Time.Sub] method subtracts two instants, producing a [Duration].
* The [Time.Add] method adds a Time and a Duration, producing a Time.
*
* The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
* As this time is unlikely to come up in practice, the [Time.IsZero] method gives
* a simple way of detecting a time that has not been initialized explicitly.
*
* Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
* Time with a specific Location. Changing the Location of a Time value with
* these methods does not change the actual instant it represents, only the time
* zone in which to interpret it.
*
* Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
* [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
* but not the location name. They therefore lose information about Daylight Saving Time.
*
* In addition to the required “wall clock” reading, a Time may contain an optional
* reading of the current process's monotonic clock, to provide additional precision
* for comparison or subtraction.
* See the “Monotonic Clocks” section in the package documentation for details.
*
* Note that the Go == operator compares not just the time instant but also the
* Location and the monotonic clock reading. Therefore, Time values should not
* be used as map or database keys without first guaranteeing that the
* identical Location has been set for all values, which can be achieved
* through use of the UTC or Local method, and that the monotonic clock reading
* has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
* to t == u, since t.Equal uses the most accurate comparison available and
* correctly handles the case when only one of its arguments has a monotonic
* clock reading.
*/
export type Time = any;
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f3ee" />
<title>OPIc Flow</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1556
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "opic-flow-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "vue-tsc --noEmit && vite build --minify false --mode development",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "latest",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
}
}
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { api } from "./api";
import type { AppSettings, Evaluation, ExamReport, Question } from "./types";
type Screen = "setup" | "exam" | "report";
const difficulties = ["IM1", "IM2", "IM3", "IH", "AL"];
const topics = ["집", "가족", "직장", "학교", "영화", "음악", "운동", "여행", "카페", "요리", "게임", "쇼핑", "공원"];
const topicIcons: Record<string, string> = {
: "⌂", 가족: "♧", 직장: "▣", 학교: "◆", 영화: "▶", 음악: "♪", 운동: "●",
여행: "✈", 카페: "☕", 요리: "♨", 게임: "✦", 쇼핑: "▤", 공원: "♿"
};
const screen = ref<Screen>("setup");
const selectedDifficulty = ref("IM2");
const selectedTopics = ref<string[]>(["영화", "여행", "카페"]);
const settingsOpen = ref(false);
const settings = ref<AppSettings>({
hasApiKey: false,
demoMode: true,
evaluationModel: "gpt-5-mini",
transcribeModel: "gpt-4o-transcribe",
realtimeModel: "gpt-realtime"
});
const apiKey = ref("");
const connectionMessage = ref("");
const busy = ref(false);
const error = ref("");
const sessionId = ref("");
const currentQuestion = ref<Question | null>(null);
const totalCount = ref(15);
const completedCount = ref(0);
const transcript = ref("");
const latestEvaluation = ref<Evaluation | null>(null);
const report = ref<ExamReport | null>(null);
const recording = ref(false);
const elapsed = ref(0);
let timer: number | undefined;
let recorder: MediaRecorder | null = null;
let stream: MediaStream | null = null;
let chunks: Blob[] = [];
let englishVoice: SpeechSynthesisVoice | null = null;
const progress = computed(() => Math.round((completedCount.value / totalCount.value) * 100));
const topicLabel = computed(() => selectedTopics.value.join(" · "));
onMounted(async () => {
await loadEnglishVoice();
try {
settings.value = await api.getSettings();
} catch {
// The UI can still be previewed in a normal browser without the Wails runtime.
}
});
onBeforeUnmount(() => {
stopTracks();
if (timer) window.clearInterval(timer);
});
function toggleTopic(topic: string) {
const index = selectedTopics.value.indexOf(topic);
if (index >= 0) selectedTopics.value.splice(index, 1);
else if (selectedTopics.value.length < 5) selectedTopics.value.push(topic);
}
async function saveSettings() {
busy.value = true;
error.value = "";
try {
settings.value = await api.configure({
apiKey: apiKey.value,
demoMode: settings.value.demoMode,
evaluationModel: settings.value.evaluationModel,
transcribeModel: settings.value.transcribeModel,
realtimeModel: settings.value.realtimeModel
});
apiKey.value = "";
settingsOpen.value = false;
} catch (e) {
error.value = readableError(e);
} finally {
busy.value = false;
}
}
async function testConnection() {
connectionMessage.value = "연결 확인 중…";
try {
if (apiKey.value) {
settings.value = await api.configure({
apiKey: apiKey.value,
demoMode: false,
evaluationModel: settings.value.evaluationModel,
transcribeModel: settings.value.transcribeModel,
realtimeModel: settings.value.realtimeModel
});
}
await api.testConnection();
connectionMessage.value = "OpenAI API 연결 성공";
} catch (e) {
connectionMessage.value = readableError(e);
}
}
async function startExam() {
if (!selectedTopics.value.length) {
error.value = "주제를 하나 이상 선택하세요.";
return;
}
busy.value = true;
error.value = "";
try {
const result = await api.startSession({
difficulty: selectedDifficulty.value,
topics: selectedTopics.value,
language: "en"
});
sessionId.value = result.sessionId;
currentQuestion.value = result.question;
totalCount.value = result.totalCount;
completedCount.value = 0;
transcript.value = "";
latestEvaluation.value = null;
screen.value = "exam";
window.setTimeout(speakQuestion, 450);
} catch (e) {
error.value = readableError(e);
} finally {
busy.value = false;
}
}
async function loadEnglishVoice(): Promise<SpeechSynthesisVoice | null> {
if (!("speechSynthesis" in window)) return null;
let voices = speechSynthesis.getVoices();
if (!voices.length) {
voices = await new Promise<SpeechSynthesisVoice[]>((resolve) => {
const timeout = window.setTimeout(() => resolve(speechSynthesis.getVoices()), 1200);
speechSynthesis.addEventListener("voiceschanged", () => {
window.clearTimeout(timeout);
resolve(speechSynthesis.getVoices());
}, { once: true });
});
}
const englishVoices = voices.filter((voice) => /^en[-_]/i.test(voice.lang));
const preference = [
/Microsoft.*Online.*Natural.*English.*United States/i,
/Microsoft (Ava|Aria|Jenny|Emma|Andrew|Brian).*Natural/i,
/Microsoft (Ava|Aria|Jenny|Emma|Andrew|Brian)/i,
/Google US English/i,
/Samantha/i,
/English.*United States/i
];
englishVoice =
preference
.map((pattern) => englishVoices.find((voice) => pattern.test(`${voice.name} ${voice.lang}`)))
.find(Boolean) ??
englishVoices.find((voice) => /^en-US$/i.test(voice.lang) && voice.localService) ??
englishVoices.find((voice) => /^en-US$/i.test(voice.lang)) ??
englishVoices[0] ??
null;
return englishVoice;
}
async function speakQuestion() {
if (!currentQuestion.value || !("speechSynthesis" in window)) return;
speechSynthesis.cancel();
const voice = englishVoice ?? await loadEnglishVoice();
const naturalEnglish = currentQuestion.value.text
.replace(/\s+/g, " ")
.replace(/\s+([,.?!])/g, "$1")
.trim();
const utterance = new SpeechSynthesisUtterance(naturalEnglish);
utterance.lang = "en-US";
if (voice) utterance.voice = voice;
utterance.rate = 0.88;
utterance.pitch = 0.98;
utterance.volume = 1;
speechSynthesis.speak(utterance);
}
async function toggleRecording() {
if (recording.value) {
recorder?.stop();
return;
}
error.value = "";
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferred = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
recorder = new MediaRecorder(stream, { mimeType: preferred });
chunks = [];
recorder.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
recorder.onstop = () => {
recording.value = false;
if (timer) window.clearInterval(timer);
stopTracks();
};
recorder.start(250);
elapsed.value = 0;
recording.value = true;
timer = window.setInterval(() => elapsed.value++, 1000);
} catch {
error.value = "마이크 권한을 확인하세요. 텍스트 답변으로도 진행할 수 있습니다.";
}
}
function stopTracks() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
}
async function submitAnswer() {
if (!currentQuestion.value || busy.value) return;
if (recording.value) recorder?.stop();
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,
questionIdx: currentQuestion.value.index - 1,
audioBase64,
audioMime: blob?.type || "",
transcript: transcript.value,
durationSec: elapsed.value
});
transcript.value = response.transcript;
latestEvaluation.value = response.evaluation;
completedCount.value = response.progress;
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);
} catch (e) {
error.value = readableError(e);
} finally {
busy.value = false;
}
}
function resetExam() {
speechSynthesis?.cancel();
screen.value = "setup";
report.value = null;
currentQuestion.value = null;
latestEvaluation.value = null;
transcript.value = "";
completedCount.value = 0;
}
function printReport() {
window.print();
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(String(reader.result).split(",")[1] || "");
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function readableError(value: unknown) {
if (value instanceof Error) return value.message;
return String(value).replace(/^.*?: /, "");
}
</script>
<template>
<div class="app-shell">
<header class="topbar">
<button class="brand" @click="resetExam">
<span class="brand-mark">O</span>
<span><strong>OPIc Flow</strong><small>AI SPEAKING LAB</small></span>
</button>
<div class="top-actions">
<span class="connection-pill" :class="{ live: settings.hasApiKey && !settings.demoMode }">
<i></i>{{ settings.demoMode ? "DEMO MODE" : "OPENAI CONNECTED" }}
</span>
<button class="icon-button" aria-label="설정" @click="settingsOpen = true"></button>
</div>
</header>
<main v-if="screen === 'setup'" class="setup-layout">
<section class="hero">
<p class="eyebrow">PERSONAL AI INTERVIEWER</p>
<h1>말할수록,<br /><em>정확해지는</em> 영어.</h1>
<p class="hero-copy">실제 OPIc 흐름으로 질문을 듣고 답하세요. AI가 문항을 분석하고 다음 연습 방향을 제시합니다.</p>
<div class="hero-stats">
<div><strong>15</strong><span>Questions</span></div>
<div><strong>AI</strong><span>Live feedback</span></div>
<div><strong>01</strong><span>Final report</span></div>
</div>
</section>
<section class="setup-card">
<div class="step-heading"><span>01</span><div><h2>목표 난이도</h2><p>현재 목표 등급을 선택하세요.</p></div></div>
<div class="difficulty-grid">
<button v-for="level in difficulties" :key="level" :class="{ selected: selectedDifficulty === level }" @click="selectedDifficulty = level">
<strong>{{ level }}</strong><small>{{ level === "AL" ? "Advanced" : "Intermediate" }}</small>
</button>
</div>
<div class="divider"></div>
<div class="step-heading"><span>02</span><div><h2>설문 주제</h2><p>자신 있게 말할 있는 주제를 최대 5 고르세요.</p></div><b>{{ selectedTopics.length }}/5</b></div>
<div class="topic-grid">
<button v-for="topic in topics" :key="topic" :class="{ selected: selectedTopics.includes(topic) }" @click="toggleTopic(topic)">
<span>{{ topicIcons[topic] }}</span>{{ topic }}
</button>
</div>
<div class="session-summary">
<div><small>YOUR SESSION</small><strong>{{ selectedDifficulty }} · {{ topicLabel }}</strong></div>
<button class="primary" :disabled="busy" @click="startExam">{{ busy ? "준비 " : "연습 시작" }} <span></span></button>
</div>
<p v-if="error" class="error">{{ error }}</p>
</section>
</main>
<main v-else-if="screen === 'exam'" class="exam-layout">
<aside class="exam-sidebar">
<p class="eyebrow">MOCK INTERVIEW</p>
<h2>{{ selectedDifficulty }} Session</h2>
<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 class="exam-tip"><small>COACH'S NOTE</small><p>완벽한 문법보다 구체적인 경험과 자연스러운 이야기 흐름에 집중하세요.</p></div>
</aside>
<section class="conversation">
<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>
</div>
<div 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>
<span class="audio-bars" :class="{ active: recording }"><i v-for="n in 18" :key="n"></i></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>
</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>
<p v-if="error" class="error">{{ error }}</p>
</section>
</main>
<main v-else-if="report && screen === 'report'" class="report-layout">
<section class="report-cover">
<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>
<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>
</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>
</div>
<div class="answer-list">
<article v-for="answer in report.answers" :key="answer.question.index" class="answer-report">
<header><span>Q{{ answer.question.index.toString().padStart(2, "0") }}</span><div><small>{{ answer.question.category }} · {{ answer.question.topic }}</small><h3>{{ answer.question.text }}</h3></div><strong>{{ answer.evaluation.score }}</strong></header>
<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>
</article>
</div>
</section>
</main>
<div v-if="settingsOpen" class="modal-backdrop" @click.self="settingsOpen = false">
<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>
<label class="switch-row"><span><b>데모 모드</b><small>API 비용 없이 전체 흐름을 시험합니다.</small></span><input v-model="settings.demoMode" type="checkbox" /></label>
<div 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>
</div>
<p v-if="connectionMessage" class="connection-message">{{ connectionMessage }}</p>
<p v-if="error" class="error">{{ error }}</p>
<footer><button class="secondary" @click="testConnection">연결 테스트</button><button class="primary" :disabled="busy" @click="saveSettings">설정 저장</button></footer>
</section>
</div>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
import { AppService } from "../bindings/opiccoach";
import type { AppSettings, ExamReport, Evaluation, Question } from "./types";
export const api = {
getSettings: () => AppService.GetSettings() as Promise<AppSettings>,
configure: (settings: Record<string, unknown>) =>
AppService.Configure(settings as never) as Promise<AppSettings>,
testConnection: () => AppService.TestConnection() as Promise<void>,
startSession: (config: Record<string, unknown>) =>
AppService.StartSession(config as never) as Promise<{
sessionId: string;
question: Question;
totalCount: number;
}>,
submitAnswer: (answer: Record<string, unknown>) =>
AppService.SubmitAnswer(answer as never) as Promise<{
transcript: string;
evaluation: Evaluation;
next?: Question;
completed: boolean;
progress: number;
}>,
getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise<ExamReport>
};
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+176
View File
@@ -0,0 +1,176 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap");
:root {
font-family: "DM Sans", "Noto Sans KR", sans-serif;
color: #16241d;
background: #f3f3ee;
font-synthesis: none;
--ink: #14251c;
--muted: #758078;
--line: #dfe2d9;
--paper: #fbfbf7;
--green: #183f2b;
--lime: #c9f36a;
--orange: #f06a3c;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 900px; min-height: 100vh; background: #f3f3ee; }
button, input, textarea { font: inherit; }
button { color: inherit; }
.app-shell { min-height: 100vh; }
.topbar { height: 76px; padding: 0 4.5vw; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(250,250,246,.9); }
.brand { display: flex; align-items: center; gap: 12px; border: 0; background: none; cursor: pointer; text-align: left; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; color: var(--lime); background: var(--green); border-radius: 50% 50% 48% 42%; font: 800 19px Manrope; }
.brand strong { display: block; font: 800 19px Manrope; letter-spacing: -.6px; }
.brand small { display: block; margin-top: 2px; font-size: 8px; letter-spacing: 2.1px; color: #8a938d; }
.top-actions { display: flex; align-items: center; gap: 14px; }
.connection-pill { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 99px; font-size: 9px; font-weight: 700; letter-spacing: 1.2px; }
.connection-pill i { width: 7px; height: 7px; border-radius: 50%; background: #e7a436; }
.connection-pill.live i { background: #45a86e; box-shadow: 0 0 0 4px #45a86e22; }
.icon-button { width: 38px; height: 38px; border: 1px solid var(--line); border-radius: 50%; background: white; cursor: pointer; }
.setup-layout { display: grid; grid-template-columns: .78fr 1.22fr; min-height: calc(100vh - 76px); }
.hero { padding: 9vh 5vw; position: relative; overflow: hidden; background: var(--green); color: white; }
.hero::after { content: ""; position: absolute; width: 430px; height: 430px; left: -130px; bottom: -210px; border: 1px solid #ffffff25; border-radius: 50%; box-shadow: 0 0 0 80px #ffffff08, 0 0 0 160px #ffffff05; }
.eyebrow { margin: 0 0 28px; color: #92a198; font-size: 10px; font-weight: 700; letter-spacing: 2.5px; }
.hero .eyebrow { color: var(--lime); }
.hero h1, .report-cover h1 { margin: 0; font: 500 clamp(45px, 5vw, 78px)/.98 Manrope; letter-spacing: -4px; }
.hero h1 em, .report-cover h1 em { color: var(--lime); font-style: normal; }
.hero-copy { max-width: 430px; margin: 34px 0; color: #c6d0ca; font-size: 15px; line-height: 1.8; }
.hero-stats { position: absolute; left: 5vw; right: 5vw; bottom: 8vh; display: flex; gap: 40px; z-index: 1; }
.hero-stats div { border-left: 1px solid #ffffff33; padding-left: 15px; }
.hero-stats strong { display: block; font: 600 25px Manrope; }
.hero-stats span { color: #90a198; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; }
.setup-card { padding: 6vh 6vw; background: var(--paper); }
.step-heading { display: flex; align-items: flex-start; gap: 15px; }
.step-heading > span { display: grid; place-items: center; width: 31px; height: 31px; border-radius: 50%; background: var(--green); color: white; font-size: 10px; font-weight: 700; }
.step-heading h2 { margin: 0; font: 700 20px Manrope; }
.step-heading p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.step-heading b { margin-left: auto; color: var(--orange); font-size: 12px; }
.difficulty-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 25px 0 0 46px; }
.difficulty-grid button { padding: 18px 8px; border: 1px solid var(--line); background: white; cursor: pointer; }
.difficulty-grid strong, .difficulty-grid small { display: block; }
.difficulty-grid strong { font: 700 16px Manrope; }
.difficulty-grid small { margin-top: 4px; color: var(--muted); font-size: 8px; }
.difficulty-grid button.selected { border-color: var(--green); color: white; background: var(--green); box-shadow: inset 0 -3px var(--lime); }
.difficulty-grid button.selected small { color: #afc0b6; }
.divider { height: 1px; background: var(--line); margin: 42px 0 34px; }
.topic-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 24px 0 0 46px; }
.topic-grid button { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 3px; background: white; font-size: 11px; cursor: pointer; }
.topic-grid button span { color: #91a098; font-size: 15px; }
.topic-grid button.selected { border-color: #b8d771; background: #eff8dc; color: var(--green); font-weight: 700; }
.topic-grid button.selected span { color: var(--green); }
.session-summary { margin-top: 42px; padding: 20px 0 0; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--line); }
.session-summary small { display: block; color: var(--muted); font-size: 8px; letter-spacing: 1.6px; }
.session-summary strong { display: block; margin-top: 7px; max-width: 350px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.primary, .secondary { min-height: 44px; padding: 0 20px; border-radius: 2px; border: 1px solid var(--green); cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .4px; }
.primary { color: white; background: var(--green); }
.primary span { margin-left: 18px; color: var(--lime); font-size: 17px; }
.primary:disabled { opacity: .5; cursor: wait; }
.secondary { background: transparent; color: var(--green); }
.error { padding: 10px 14px; color: #a23a24; background: #fff0e9; font-size: 12px; border-left: 3px solid var(--orange); }
.exam-layout { display: grid; grid-template-columns: 285px 1fr; min-height: calc(100vh - 76px); }
.exam-sidebar { padding: 52px 36px; color: white; background: var(--green); }
.exam-sidebar h2 { margin: -14px 0 35px; font: 600 25px Manrope; }
.progress-ring { --progress: 0deg; width: 150px; height: 150px; margin: 0 auto 30px; display: grid; place-items: center; border-radius: 50%; background: conic-gradient(var(--lime) var(--progress), #ffffff18 0); position: relative; }
.progress-ring::after { content: ""; position: absolute; inset: 9px; border-radius: 50%; background: var(--green); }
.progress-ring span { z-index: 1; color: #9aac9f; font-size: 12px; }
.progress-ring strong { color: white; font: 600 38px Manrope; }
.progress-meta { display: flex; justify-content: space-between; font-size: 9px; letter-spacing: 1px; color: #9caf9f; }
.progress-meta b { color: var(--lime); }
.progress-bar { height: 3px; margin-top: 9px; background: #ffffff20; }
.progress-bar i { display: block; height: 100%; background: var(--lime); transition: width .4s; }
.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; }
.conversation { padding: 5vh 7vw 8vh; background: var(--paper); }
.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; }
.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; }
.answer-area { margin-top: 32px; }
.record-row { display: flex; align-items: center; gap: 18px; }
.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; }
.timer { font: 600 13px Manrope; font-variant-numeric: tabular-nums; }
.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; }
.audio-bars i:nth-child(3n) { animation-delay: .2s; }.audio-bars i:nth-child(4n) { animation-delay: .35s; }
@keyframes wave { to { height: 24px; } } @keyframes pulse { 50% { box-shadow: 0 0 0 6px #f06a3c28; } }
.answer-area label, .settings-modal label { display: block; margin: 22px 0 9px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; }
.answer-area label small { margin-left: 8px; color: var(--muted); font-weight: 400; letter-spacing: 0; text-transform: none; }
textarea { width: 100%; height: 125px; padding: 17px; resize: vertical; border: 1px solid var(--line); outline: none; background: white; line-height: 1.7; }
textarea:focus, input:focus { border-color: #7e9b88; box-shadow: 0 0 0 3px #7e9b8815; }
.submit-row { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.submit-row > span { color: var(--muted); font-size: 10px; }
.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; }
.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); }
.report-cover > p:not(.eyebrow):not(.report-summary) { color: #99aa9f; font-size: 11px; }
.overall-score { margin-top: 5vh; padding: 24px 0; display: flex; align-items: center; gap: 35px; border-top: 1px solid #ffffff22; border-bottom: 1px solid #ffffff22; }
.overall-score strong { font: 600 68px Manrope; color: var(--lime); }
.overall-score span { color: #8ea096; }
.overall-score aside { padding-left: 30px; border-left: 1px solid #ffffff22; }
.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; }
.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 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; }
.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; }
.answer-report header > span { font: 700 15px Manrope; color: #8a948d; }
.answer-report h3 { margin: 5px 0 0; font: 600 16px/1.45 Manrope; }
.answer-report header > strong { display: grid; place-items: center; width: 50px; height: 50px; border-radius: 50%; color: white; background: var(--green); }
blockquote { margin: 18px 0; padding: 16px 18px; color: #526057; background: #f1f2ed; font-size: 11px; line-height: 1.7; border-left: 2px solid #aab5ad; }
.keyword-row { display: flex; gap: 6px; flex-wrap: wrap; }
.keyword-row span { padding: 5px 8px; border-radius: 99px; color: #48604f; background: #e5eee7; font-size: 9px; }
.feedback-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 15px; }
.feedback-grid div { padding: 14px; border: 1px solid var(--line); }
.feedback-grid p { margin: 6px 0 0; color: #59665e; font-size: 10px; line-height: 1.6; }
.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; }
.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; }
.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; }
.switch-row input { width: 18px; height: 18px; }
.model-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.model-grid label:last-child { grid-column: 1 / -1; }
.settings-modal footer { margin-top: 25px; display: flex; justify-content: flex-end; gap: 8px; }
.connection-message { padding: 10px; background: #eef4dd; font-size: 11px; }
@media (max-width: 1050px) {
.setup-layout { grid-template-columns: .68fr 1.32fr; }
.setup-card { padding-left: 4vw; padding-right: 4vw; }
.topic-grid { grid-template-columns: repeat(4, 1fr); }
.hero h1 { font-size: 52px; }
}
@media print {
.topbar, .report-actions { display: none; }
.report-layout { display: block; }
.report-cover { position: static; height: auto; color: #14251c; background: white; }
.report-cover h1 em, .overall-score strong { color: #14251c; }
.report-content { padding: 20px; }
.answer-report { break-inside: avoid; }
}
+45
View File
@@ -0,0 +1,45 @@
export interface AppSettings {
hasApiKey: boolean;
demoMode: boolean;
evaluationModel: string;
transcribeModel: string;
realtimeModel: string;
}
export interface Question {
index: number;
category: string;
topic: string;
text: string;
intent: string;
}
export interface Evaluation {
score: number;
keywords: string[];
strengths: string[];
improvements: string[];
feedback: string;
}
export interface AnswerRecord {
question: Question;
transcript: string;
durationSec: number;
evaluation: Evaluation;
}
export interface ExamReport {
sessionId: string;
config: { difficulty: string; topics: string[]; language: string };
overall: {
score: number;
estimatedBand: string;
summary: string;
strengths: string[];
priorities: string[];
};
answers: AnswerRecord[];
generatedAt: string;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import wails from "@wailsio/runtime/plugins/vite";
export default defineConfig({
server: {
host: "127.0.0.1",
port: Number(process.env.WAILS_VITE_PORT) || 9245,
strictPort: true
},
plugins: [vue(), wails("./bindings")]
});