Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Generated
+1556
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user