Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fbf147b
feat: #86etkgdqu implement Gemini Live API for live sessions
navidshad May 7, 2026
5294d50
chore: #86etkgdqu document and clean up Gemini live-session code
navidshad May 7, 2026
bbb1596
refactor: #86etkgdqu split live-session by provider into openai/ and …
navidshad May 7, 2026
161a413
refactor: centralize price calculation logic and apply cost markup to…
navidshad May 7, 2026
61b8208
chore: #86etkgdqu disable OpenAI live-session flow on the frontend
navidshad May 7, 2026
119e5d8
chore: #86etkgdqu detach OpenAI flow, default live-session route to G…
navidshad May 7, 2026
847486f
chore: remove obsolete instagram connection and status pages
navidshad May 7, 2026
e444d70
chore: renamed openai store
navidshad May 8, 2026
6002531
chore: tweak on functions files
navidshad May 8, 2026
33038ae
refactor: improve Gemini live session message handling and granular d…
navidshad May 8, 2026
8cebf65
feat: update Gemini token tracking to include tool use, thoughts, and…
navidshad May 8, 2026
3b89c48
refactor: redesign live session UI with improved phrase cards, chat t…
navidshad May 8, 2026
bd02253
style: fix phrase card layout with fixed widths, text truncation, and…
navidshad May 8, 2026
042e67f
feat: add mic level visualization and spacebar toggle, and include tr…
navidshad May 8, 2026
59b81f4
refactor: localize UI text and labels in live session components usin…
navidshad May 8, 2026
a0b77ab
feat: add native language selection for AI sessions and implement tex…
navidshad May 8, 2026
9b8a85f
feat: reset session state on creation and improve native language det…
navidshad May 8, 2026
de37822
feat: add opt-in text-based message composer with microphone toggle f…
navidshad May 8, 2026
f8650de
fix: restrict phrase marking as practiced to user input instead of tr…
navidshad May 8, 2026
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
2 changes: 1 addition & 1 deletion frontend/components/bundle/MicToggle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<script setup lang="ts">
import { Icon } from 'pilotui/elements';
import { ref, computed, watch } from 'vue';
import { useLiveSessionStore } from '~/stores/liveSession';
import { useLiveSessionStore } from '~/stores/liveSessionOpenai';

const liveSessionStore = useLiveSessionStore();

Expand Down
189 changes: 189 additions & 0 deletions frontend/components/bundle/MicToggleGemini.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<template>
<div class="toggle-container">
<div class="toggle-switch" :class="{ 'right-active': !isToggleMode }">
<!-- Left side: tap to mute/unmute -->
<div class="toggle-side left" :class="{ active: isToggleMode }" @click="handleLeftClick"
:title="t('live-practice.mic-toggle.tap-tooltip')">
<template v-if="isToggleMode">
<Icon
:name="isMuted ? 'iconify clarity--microphone-mute-line' : 'iconify solar--microphone-line-duotone'"
class="mic-icon" :class="{ muted: isMuted, unmuted: !isMuted }" />
</template>
<span v-else class="side-text">{{ t('live-practice.mic-toggle.tap-label') }}</span>
</div>

<!-- Right side: always unmuted (hands-free) -->
<div class="toggle-side right" :class="{ active: !isToggleMode }" @click="handleRightClick"
:title="t('live-practice.mic-toggle.hands-free-tooltip')">
<template v-if="!isToggleMode">
<Icon name="iconify solar--microphone-line-duotone" class="mic-icon unmuted" />
</template>
<span v-else class="side-text">{{ t('live-practice.mic-toggle.hands-free-label') }}</span>
</div>

<!-- Sliding indicator -->
<div class="slider" :class="{ right: !isToggleMode }"></div>
</div>
</div>
</template>

<script setup lang="ts">
// Mic mute/unmute toggle bound to the Gemini live-session store.
import { Icon } from 'pilotui/elements';
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useLiveSessionGeminiStore } from '~/stores/liveSessionGemini';

const { t } = useI18n();
const liveSessionStore = useLiveSessionGeminiStore();

const isToggleMode = ref(true);

const isMuted = computed(() => liveSessionStore.getMicrophoneMuted);

function switchToToggleMode() {
if (!isToggleMode.value) {
isToggleMode.value = true;
if (!isMuted.value) {
liveSessionStore.toggleMicrophone(false);
}
}
}

function switchToAlwaysOn() {
if (isToggleMode.value) {
isToggleMode.value = false;
if (isMuted.value) {
liveSessionStore.toggleMicrophone(true);
}
}
}

function handleLeftClick() {
if (isToggleMode.value) {
liveSessionStore.toggleMicrophone();
} else {
switchToToggleMode();
}
}

function handleRightClick() {
if (!isToggleMode.value) {
return;
} else {
switchToAlwaysOn();
}
}

// Global spacebar shortcut: toggles the mic from anywhere on the page while
// we're in tap-to-toggle mode. Skipped if the user is typing in an input or
// already in hands-free, so we don't fight unrelated keyboard interactions.
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}

function onSpaceKeyDown(event: KeyboardEvent) {
if (event.code !== 'Space') return;
if (event.repeat) return;
if (!isToggleMode.value) return;
if (isEditableTarget(event.target)) return;
event.preventDefault();
liveSessionStore.toggleMicrophone();
}

onMounted(() => {
window.addEventListener('keydown', onSpaceKeyDown);
});

onUnmounted(() => {
window.removeEventListener('keydown', onSpaceKeyDown);
});

watch(isToggleMode, (newValue) => {
if (!newValue && isMuted.value) {
liveSessionStore.toggleMicrophone();
}
});

watch(
() => liveSessionStore.getMicrophoneMuted,
(newValue) => {
if (newValue == false) {
isToggleMode.value = true;
}
},
{ immediate: true, deep: true, once: true }
);
</script>

<style scoped>
.toggle-container {
display: flex;
justify-content: center;
align-items: center;
}

.toggle-switch {
position: relative;
width: 320px;
height: 48px;
background: #f5f5f5;
border-radius: 24px;
display: flex;
cursor: pointer;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}

.toggle-side {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
transition: all 0.3s ease;
padding: 0 12px;
}

.slider {
position: absolute;
width: 50%;
height: 100%;
background: white;
border-radius: 24px;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.slider.right {
transform: translateX(100%);
}

.mic-icon {
font-size: 24px;
transition: all 0.3s ease;
}

.mic-icon.muted {
color: #f44336;
}

.mic-icon.unmuted {
color: #4caf50;
}

.side-text {
font-size: 7px;
color: #666;
white-space: nowrap;
}

.toggle-switch:hover .slider {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.toggle-side:hover {
opacity: 0.9;
}
</style>
59 changes: 49 additions & 10 deletions frontend/components/bundle/StartLiveSessionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@
</select>
</div>

<!-- Native Language Selection -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('live-practice.native-language') }}
</label>
<p class="mb-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('live-practice.native-language-hint') }}
</p>
<select v-model="formData.nativeLanguage"
class="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="auto">{{ t('live-practice.native-language-auto') }}</option>
<option v-for="lang in SUPPORTED_LANGUAGES" :key="lang.code" :value="lang.title">
{{ lang.title }}
</option>
</select>
</div>

<!-- Selection Tabs -->
<div>
<div class="mb-2 flex rounded-md bg-gray-100 p-1 dark:bg-gray-700">
Expand Down Expand Up @@ -74,18 +91,39 @@

<script setup lang="ts">
import { Input } from 'pilotui';
import { SUPPORTED_LANGUAGES } from '~/utils/languages.static';

const { t } = useI18n();

const props = defineProps<{
modelValue: {
aiCharacter: string;
selectionMode: 'selection' | 'random';
fromPhrase: string;
toPhrase: string;
totalPhrases: string;
};
}>();
// Default voice list (Gemini Live API prebuilt voices). Inlined here rather
// than pulled from a `const` because `withDefaults` is hoisted by the Vue
// compiler and can't reference local script-setup variables. The OpenAI
// `StartNew` variant passes its own list via the `voiceOptions` prop.
const props = withDefaults(
defineProps<{
modelValue: {
aiCharacter: string;
selectionMode: 'selection' | 'random';
fromPhrase: string;
toPhrase: string;
totalPhrases: string;
nativeLanguage: string;
};
voiceOptions?: string[];
}>(),
{
voiceOptions: () => [
'Kore',
'Puck',
'Charon',
'Fenrir',
'Aoede',
'Leda',
'Orus',
'Zephyr',
],
}
);

const emit = defineEmits<{
'update:modelValue': [
Expand All @@ -95,11 +133,12 @@ const emit = defineEmits<{
fromPhrase: string;
toPhrase: string;
totalPhrases: string;
nativeLanguage: string;
}
];
}>();

const aiCharacters = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'sage', 'shimmer', 'verse'];
const aiCharacters = computed(() => props.voiceOptions);

const formData = computed({
get: () => props.modelValue,
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/bundle/StartLiveSessionFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ const emit = defineEmits<{
const formRef = ref<InstanceType<typeof StartLiveSessionForm> | null>(null);

const formData = reactive({
aiCharacter: 'alloy',
aiCharacter: 'Kore',
selectionMode: 'selection' as 'selection' | 'random',
fromPhrase: '1',
toPhrase: '10',
totalPhrases: '10',
nativeLanguage: 'auto',
});

const isFormValid = computed(() => {
Expand All @@ -83,6 +84,7 @@ function startSession() {
const sessionData = {
aiCharacter: formData.aiCharacter,
selectionMode: formData.selectionMode,
nativeLanguage: formData.nativeLanguage,
};

if (formData.selectionMode === 'selection') {
Expand Down
5 changes: 2 additions & 3 deletions frontend/components/freemium_alerts/FreemiumTimer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

<script setup lang="ts">
import { Card, Icon } from 'pilotui/elements';
import { useLiveSessionStore } from '~/stores/liveSession';
import { useLiveSessionGeminiStore } from '~/stores/liveSessionGemini';

const props = withDefaults(
defineProps<{
Expand Down Expand Up @@ -167,8 +167,7 @@ function handleTimerExpired() {
// Auto-mute mic if enabled
if (props.autoMuteMic) {
try {
// Try to access live session store
const liveSessionStore = useLiveSessionStore();
const liveSessionStore = useLiveSessionGeminiStore();
if (liveSessionStore.getMicrophoneMuted === false) {
liveSessionStore.toggleMicrophone();
}
Expand Down
Loading
Loading