Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/common/services/live-voices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { functionProvider } from "@modular-rest/client";

/**
* AI-coach voice as returned by the server's `get-live-session-voices`
* function. Kept structurally in sync with the dashboard's `CoachVoice`
* (server: live_session/voices.ts) β€” the two repos build separately so the
* shape is mirrored, not imported.
*/
export interface CoachVoice {
name: string;
label: string;
description?: string;
gender?: "female" | "male";
avatarColor?: string;
avatarUrl?: string | null;
}

/**
* Cached fetch of the coach voices. Singleton so every Practice now mount
* shares one network call (mirrors BundleSuggestionService / TranslateService).
*
* No offline fallback: if the server can't return voices, the dashboard live
* session can't run anyway, so an empty list is the honest result.
*/
export class LiveVoicesService {
private static _instance: LiveVoicesService | null = null;

static get instance(): LiveVoicesService {
if (!this._instance) this._instance = new LiveVoicesService();
return this._instance;
}

private cache: CoachVoice[] | null = null;
private inflight: Promise<CoachVoice[]> | null = null;

async getVoices(): Promise<CoachVoice[]> {
if (this.cache) return this.cache;
if (this.inflight) return this.inflight;

this.inflight = functionProvider
.run<CoachVoice[]>({ name: "get-live-session-voices", args: {} })
.then((res) => {
this.cache = res || [];
return this.cache;
})
// Don't cache failures, so a transient error retries on the next open.
.catch(() => [] as CoachVoice[])
.finally(() => {
this.inflight = null;
});

return this.inflight;
}
}
10 changes: 7 additions & 3 deletions src/common/static/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ export const VERSION = require("../../../package.json").version;

export const SUBTURTLE_DASHBOARD_URL = process.env.SUBTURTLE_DASHBOARD_URL;

export function getSubturtleDashboardUrlWithToken() {
export function getSubturtleDashboardUrlWithToken(redirectPath?: string) {
const token = authentication.getToken;
const url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`;
console.log("Subturtle dashboard url", url);
let url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`;
// The dashboard's login_with_token page reads `redirect`, validates same-origin,
// and pushes it after auth β€” so a deep-link survives the token handoff.
if (redirectPath) {
url += `&redirect=${encodeURIComponent(redirectPath)}`;
}
return url;
}

Expand Down
9 changes: 9 additions & 0 deletions src/console-crane/components/SaveWordSectionV2.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const props = defineProps<{
chunks?: Chunk[];
}>();

const emit = defineEmits<{
/** Fired after a successful save, carrying the created phrase document. */
saved: [PhraseType];
}>();

const selectBundleRef = ref();
const selectedBundles = ref<string[]>([]);
const existingBundles = ref<PhraseBundleType[]>([]);
Expand Down Expand Up @@ -352,6 +357,10 @@ async function savePhrase() {
await loadExistingBundles();
await profileStore.fetchSubscription();

if (existedPhrase.value) {
emit("saved", existedPhrase.value);
}

if (selectBundleRef.value?.closeDropdown) {
selectBundleRef.value.closeDropdown();
}
Expand Down
55 changes: 55 additions & 0 deletions src/console-crane/components/VoicePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<div>
<div v-if="loading" class="grid grid-cols-3 gap-2">
<div v-for="i in 6" :key="i" class="h-[88px] rounded-xl bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
</div>
<div v-else class="grid grid-cols-3 gap-2">
<button
v-for="v in voices"
:key="v.name"
type="button"
@click="$emit('update:modelValue', v.name)"
:class="[
'flex flex-col items-center gap-1.5 rounded-xl border p-2.5 text-center transition-all focus:outline-none',
modelValue === v.name
? 'border-purple-500 bg-purple-50 ring-2 ring-purple-500/30 dark:bg-purple-500/10'
: 'border-gray-200 hover:border-purple-300 dark:border-white/[0.08] dark:hover:border-purple-500/40',
]"
>
<img v-if="v.avatarUrl" :src="v.avatarUrl" :alt="v.label" class="h-11 w-11 rounded-full object-cover" />
<span
v-else
class="flex h-11 w-11 items-center justify-center rounded-full text-base font-semibold text-white"
:style="{ backgroundColor: v.avatarColor || '#7C3AED' }"
>{{ initial(v) }}</span>
<span class="text-xs font-medium text-gray-900 dark:text-gray-100">{{ v.label }}</span>
<span v-if="v.description" class="text-[10px] leading-tight text-gray-500 dark:text-gray-400">
{{ v.description }}
</span>
</button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { LiveVoicesService, type CoachVoice } from "../../common/services/live-voices.service";

defineProps<{ modelValue: string }>();
defineEmits<{ "update:modelValue": [value: string] }>();

const voices = ref<CoachVoice[]>([]);
const loading = ref(true);

onMounted(async () => {
try {
voices.value = await LiveVoicesService.instance.getVoices();
} finally {
loading.value = false;
}
});

function initial(v: CoachVoice): string {
return (v.label || v.name).charAt(0).toUpperCase();
}
</script>
50 changes: 50 additions & 0 deletions src/console-crane/modules/practice-config/deep-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* "Practice now" -> dashboard deep-link.
*
* Practice now is a single-phrase voice session. It hands the saved phrase to
* the dashboard's single live-session gate (`/practice/live-session`) as a
* base64-encoded LiveSessionRequest with a `phrases` source β€” the same
* descriptor the dashboard's bundle review builds (with a `bundle` source).
* The phrase is always saved first, so it's referenced by id; all values are
* ASCII, so plain base64 is enough.
*/

/** The eight Gemini voices, mirroring the dashboard's voice list. */
export const PRACTICE_NOW_VOICES = [
"Kore",
"Puck",
"Charon",
"Fenrir",
"Aoede",
"Leda",
"Orus",
"Zephyr",
] as const;

export const DEFAULT_PRACTICE_VOICE = "Kore";

export interface PracticeNowOptions {
/** The saved phrase's `_id`. */
phraseId: string;
/** The chosen coach voice (one of {@link PRACTICE_NOW_VOICES}). */
voiceName?: string;
}

/**
* The dashboard-relative path the new tab lands on after the token handoff:
* the unified live-session gate, carrying a single-phrase request.
*/
export function buildPracticeNowPath({
phraseId,
voiceName,
}: PracticeNowOptions): string {
const request = {
aiCharacter: voiceName || DEFAULT_PRACTICE_VOICE,
source: { kind: "phrases", phraseIds: [phraseId] },
returnTo: "/board",
};
const session = btoa(JSON.stringify(request));
const params = new URLSearchParams();
params.set("session", session);
return `/practice/live-session?${params.toString()}`;
}
Loading
Loading