Skip to content
Open
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
5 changes: 4 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"extends": ["@rubriclab/config/biome"]
"extends": ["@rubriclab/config/biome"],
"files": {
"includes": ["**", "!next-env.d.ts"]
}
}
182 changes: 182 additions & 0 deletions src/app/(app)/ChatMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use client'

import { useEffect, useRef, useState } from 'react'

import { useLilacModeRuntime } from '@/realtime/modeRuntimeStore'

export default function ChatMode() {
const {
chatInstructions,
chatTranscripts,
chatTurnDelaySeconds,
remoteAudioStream,
setChatInstructions,
setChatTurnDelaySeconds
} = useLilacModeRuntime()
const [draftInstructions, setDraftInstructions] = useState(chatInstructions)
const [saveMessage, setSaveMessage] = useState('')
const transcriptListRef = useRef<HTMLDivElement | null>(null)
const transcriptBottomRef = useRef<HTMLDivElement | null>(null)
const stayPinnedToBottomRef = useRef(true)
const playbackAudioElementRef = useRef<HTMLAudioElement | null>(null)

useEffect(() => {
setDraftInstructions(chatInstructions)
}, [chatInstructions])

useEffect(() => {
if (!saveMessage) return
const timeoutId = window.setTimeout(() => setSaveMessage(''), 1600)
return () => {
window.clearTimeout(timeoutId)
}
}, [saveMessage])

useEffect(() => {
const transcriptListElement = transcriptListRef.current
if (!transcriptListElement) return
const activeTranscriptListElement = transcriptListElement

function onScroll(): void {
const distanceFromBottom =
activeTranscriptListElement.scrollHeight -
activeTranscriptListElement.scrollTop -
activeTranscriptListElement.clientHeight
stayPinnedToBottomRef.current = distanceFromBottom < 120
}

activeTranscriptListElement.addEventListener('scroll', onScroll)
onScroll()
return () => {
activeTranscriptListElement.removeEventListener('scroll', onScroll)
}
}, [])

useEffect(() => {
if (!stayPinnedToBottomRef.current) return
void chatTranscripts
transcriptBottomRef.current?.scrollIntoView({ behavior: 'auto' })
}, [chatTranscripts])

useEffect(() => {
if (!playbackAudioElementRef.current) {
playbackAudioElementRef.current = new Audio()
playbackAudioElementRef.current.autoplay = true
}
const playbackAudioElement = playbackAudioElementRef.current
if (!playbackAudioElement) return
playbackAudioElement.srcObject = remoteAudioStream
if (remoteAudioStream) {
void playbackAudioElement.play().catch(() => {})
}
return () => {
playbackAudioElement.pause()
playbackAudioElement.srcObject = null
}
}, [remoteAudioStream])

return (
<div className="flex w-full max-w-5xl flex-col gap-4">
<div
ref={transcriptListRef}
className="h-[54dvh] overflow-y-auto rounded-3xl border border-white/25 bg-[var(--lilac-elevated)]/80 p-4 shadow-xl backdrop-blur"
>
{chatTranscripts.length ? (
<div className="flex flex-col gap-3">
{chatTranscripts.map(message => {
const isUser = message.role === 'user'
const bubbleBaseClasses =
'max-w-[94%] whitespace-pre-wrap rounded-3xl px-4 py-3 text-sm leading-relaxed shadow-sm'
const bubbleClasses = isUser
? `${bubbleBaseClasses} self-end bg-[var(--lilac-ink)] text-[var(--lilac-surface)]`
: `${bubbleBaseClasses} self-start border border-white/30 bg-white/80 text-[var(--lilac-ink)] dark:bg-white/12`
const label = isUser ? 'You' : 'Lilac'
return (
<div key={message.id} className="flex flex-col gap-1">
<div
className={`px-1 font-semibold text-[10px] uppercase tracking-[0.16em] ${
isUser ? 'text-right text-[var(--lilac-ink-muted)]' : 'text-[var(--lilac-ink-muted)]'
}`}
>
{label}
{message.status === 'streaming' ? <span className="ml-1 opacity-60">•</span> : null}
</div>
<div className={bubbleClasses}>{message.text.trim() || '…'}</div>
</div>
)
})}
<div ref={transcriptBottomRef} />
</div>
) : (
<div className="flex h-full items-center justify-center text-[var(--lilac-ink-muted)] text-sm">
Speak to start chatting with Lilac.
</div>
)}
</div>

<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-white/25 bg-[var(--lilac-elevated)]/80 p-4 shadow-lg backdrop-blur">
<div className="mb-2 font-semibold text-[var(--lilac-ink-muted)] text-sm uppercase tracking-[0.12em]">
Instructions
</div>
<textarea
className="h-36 w-full resize-none rounded-2xl border border-white/30 bg-white/80 px-3 py-2 text-[var(--lilac-ink)] text-sm outline-none transition focus:border-white/60 dark:bg-white/10"
onChange={event => setDraftInstructions(event.target.value)}
value={draftInstructions}
/>
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
className="cursor-pointer rounded-full px-3 py-2 font-semibold text-[var(--lilac-ink-muted)] text-xs uppercase tracking-[0.12em] transition hover:bg-white/40"
onClick={() => setDraftInstructions(chatInstructions)}
>
Reset
</button>
<button
type="button"
className="cursor-pointer rounded-full bg-[var(--lilac-ink)] px-4 py-2 font-semibold text-[var(--lilac-surface)] text-xs uppercase tracking-[0.12em] transition hover:shadow"
onClick={() => {
setChatInstructions(draftInstructions.trim() || defaultChatInstructions)
setSaveMessage('Saved')
}}
>
Save
</button>
</div>
{saveMessage ? (
<output className="mt-2 block text-[var(--lilac-ink-muted)] text-xs" aria-live="polite">
{saveMessage}
</output>
) : null}
</div>

<div className="rounded-3xl border border-white/25 bg-[var(--lilac-elevated)]/80 p-4 shadow-lg backdrop-blur">
<div className="mb-2 font-semibold text-[var(--lilac-ink-muted)] text-sm uppercase tracking-[0.12em]">
End-of-speech delay
</div>
<p className="mb-4 text-[var(--lilac-ink-muted)] text-sm">
Increase this if Lilac responds before you finish speaking.
</p>
<input
className="lilac-range"
max={6}
min={0.2}
onChange={event => setChatTurnDelaySeconds(Number.parseFloat(event.target.value))}
step={0.1}
type="range"
value={chatTurnDelaySeconds}
/>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-[var(--lilac-ink-muted)]">Delay</span>
<span className="font-semibold text-[var(--lilac-ink)]">
{chatTurnDelaySeconds.toFixed(1)}s
</span>
</div>
</div>
</div>
</div>
)
}

const defaultChatInstructions =
'You are Lilac. Help users communicate across languages. Keep answers concise, faithful, and practical.'
125 changes: 125 additions & 0 deletions src/app/(app)/ModeShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client'

import type { ReactNode } from 'react'

import ChatMode from '@/app/(app)/ChatMode'
import TranscribeMode from '@/app/(app)/TranscribeMode'
import TranslateMode from '@/app/(app)/TranslateMode'
import { useLilacModeRuntime } from '@/realtime/modeRuntimeStore'
import type { LilacMode } from '@/realtime/sessionTypes'

function renderMode(mode: LilacMode): ReactNode {
switch (mode) {
case 'chat':
return <ChatMode />
case 'translate':
return <TranslateMode />
case 'transcribe':
return <TranscribeMode />
default:
return <ChatMode />
}
}

function getConnectionStateLabel(connectionState: string): string {
switch (connectionState) {
case 'connecting':
return 'Connecting'
case 'connected':
return 'Live'
case 'error':
return 'Error'
default:
return 'Idle'
}
}

export default function ModeShell() {
const {
clearCurrentModeHistory,
connectionState,
errorMessage,
mode,
reconnectCurrentMode,
setMode
} = useLilacModeRuntime()

return (
<div className="relative flex h-svh flex-col overflow-hidden">
<div className="-z-10 pointer-events-none absolute inset-0 bg-[radial-gradient(220%_200%_at_50%_-12%,rgba(255,255,255,0.96)_0%,rgba(247,243,231,0.98)_48%,rgba(247,243,231,1)_72%,rgba(188,226,255,0.65)_100%)] dark:bg-[radial-gradient(220%_200%_at_50%_-12%,rgba(33,39,56,0.95)_0%,rgba(18,22,32,0.98)_52%,rgba(14,18,27,1)_78%,rgba(39,55,92,0.62)_100%)]" />
<div className="-z-10 pointer-events-none absolute inset-x-0 top-0 h-[26dvh] bg-gradient-to-b from-white/65 via-transparent to-transparent dark:from-[#27324a]/50" />
<div className="-z-10 pointer-events-none absolute inset-x-0 bottom-0 h-[30dvh] bg-gradient-to-t from-[var(--lilac-surface)] via-transparent to-transparent" />

<header
className="absolute inset-x-0 z-20 flex items-center justify-between px-6"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 1.5rem)' }}
>
<div className="font-semibold text-[var(--lilac-ink-muted)] text-sm uppercase tracking-[0.16em]">
Lilac
</div>
<div className="rounded-full border border-white/25 bg-[var(--lilac-elevated)] p-1 shadow backdrop-blur">
<div className="flex items-center gap-1" role="tablist">
{(['chat', 'translate', 'transcribe'] as const).map(modeOption => (
<button
key={modeOption}
type="button"
aria-pressed={mode === modeOption}
className={`cursor-pointer rounded-full px-3 py-2 font-semibold text-xs uppercase tracking-[0.08em] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 ${
mode === modeOption
? 'bg-white text-[var(--lilac-ink)] shadow'
: 'text-[var(--lilac-ink-muted)] hover:text-[var(--lilac-ink)]'
}`}
onClick={() => setMode(modeOption)}
>
{modeOption}
</button>
))}
</div>
</div>
</header>

<div className="relative z-10 flex flex-1 items-center justify-center px-6 pt-28 pb-24">
<div className="flex w-full max-w-6xl flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="rounded-full border border-white/25 bg-[var(--lilac-elevated)] px-3 py-1 font-semibold text-[10px] text-[var(--lilac-ink-muted)] uppercase tracking-[0.14em] shadow-sm">
{mode}
</div>
<div className="rounded-full border border-white/25 bg-[var(--lilac-elevated)] px-3 py-1 font-semibold text-[10px] text-[var(--lilac-ink-muted)] uppercase tracking-[0.14em] shadow-sm">
{getConnectionStateLabel(connectionState)}
</div>
</div>

{errorMessage ? (
<div className="rounded-2xl border border-red-300/60 bg-red-50/90 px-4 py-3 text-red-800 text-sm shadow-sm dark:border-red-500/40 dark:bg-red-900/25 dark:text-red-100">
{errorMessage}
</div>
) : null}

{renderMode(mode)}
</div>
</div>

<footer
className="absolute inset-x-0 z-10 flex justify-center px-6"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 1.5rem)' }}
>
<div className="flex items-center gap-2 rounded-full border border-white/25 bg-[var(--lilac-elevated)] p-1 shadow backdrop-blur">
<button
type="button"
className="cursor-pointer rounded-full px-3 py-2 font-semibold text-[10px] text-[var(--lilac-ink-muted)] uppercase tracking-[0.12em] transition hover:bg-white/50 hover:text-[var(--lilac-ink)]"
onClick={clearCurrentModeHistory}
>
Clear
</button>
<button
type="button"
className="cursor-pointer rounded-full bg-[var(--lilac-ink)] px-4 py-2 font-semibold text-[10px] text-[var(--lilac-surface)] uppercase tracking-[0.12em] transition hover:shadow"
onClick={reconnectCurrentMode}
>
Reconnect
</button>
</div>
</footer>
</div>
)
}
Loading