-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add chat UI, status HUD, and audio transcription #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -274,17 +274,36 @@ func (p *Proxy) handleServerContent(content *genai.LiveServerContent) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.sendBinary(part.InlineData.Data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if part.Text != "" && !part.Thought { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Capture non-thinking transcript for analyze_user context. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Capture non-thinking transcript for tool context (analyze_user). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Browser display uses OutputTranscription to avoid duplicates. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.toolHandler.AddTranscript("model", part.Text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Forward transcript as JSON (skip model thinking/reasoning text). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.sendJSON(map[string]any{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "type": "transcript", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "role": "model", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "text": part.Text, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Forward input transcription (what the user said). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if content.InputTranscription != nil && content.InputTranscription.Text != "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only persist finalized user speech to tool context. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if content.InputTranscription.Finished { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.toolHandler.AddTranscript("user", content.InputTranscription.Text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.sendJSON(map[string]any{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+285
to
+290
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with ๐ย / ๐. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "type": "transcript", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "role": "user", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "text": content.InputTranscription.Text, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "finished": content.InputTranscription.Finished, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Forward output transcription (what the model said, as text). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if content.OutputTranscription != nil && content.OutputTranscription.Text != "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p.sendJSON(map[string]any{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "type": "transcript", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "role": "model", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "text": content.OutputTranscription.Text, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "finished": content.OutputTranscription.Finished, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+299
to
+304
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new branch emits a second Useful? React with ๐ย / ๐. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+299
to
+306
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new block for forwarding the model's output transcription appears to duplicate existing logic. The loop over
Comment on lines
+284
to
+306
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ์ ์ฌ ์ด๋ฒคํธ๋ฅผ ์ด์ค/๋ถ๋ถ ๋์ ์ผ๋ก ๋ณด๋ด์ ์ฑํ ์ค๋ณต๊ณผ ์ปจํ ์คํธ ์ค์ผ์ด ์๊ธธ ์ ์์ต๋๋ค. Line 276 ๊ฒฝ๋ก( ๐งฉ ์ ์ ํจ์น- // Forward input transcription (what the user said).
+ // Forward input transcription (what the user said).
if content.InputTranscription != nil && content.InputTranscription.Text != "" {
- p.toolHandler.AddTranscript("user", content.InputTranscription.Text)
+ if content.InputTranscription.Finished {
+ p.toolHandler.AddTranscript("user", content.InputTranscription.Text)
+ }
p.sendJSON(map[string]any{
"type": "transcript",
"role": "user",
"text": content.InputTranscription.Text,
"finished": content.InputTranscription.Finished,
})
}
- // Forward output transcription (what the model said, as text).
- if content.OutputTranscription != nil && content.OutputTranscription.Text != "" {
+ // Forward output transcription (what the model said, as text).
+ // Prefer a single model transcript source to avoid duplicates with ModelTurn.Part.Text.
+ if content.OutputTranscription != nil && content.OutputTranscription.Text != "" {
+ p.toolHandler.AddTranscript("model", content.OutputTranscription.Text)
p.sendJSON(map[string]any{
"type": "transcript",
"role": "model",
"text": content.OutputTranscription.Text,
"finished": content.OutputTranscription.Finished,
})
}๐ Committable suggestion
Suggested change
๐ค Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // handleToolCall executes a tool and sends the response back to Live API. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,24 +9,22 @@ import SceneDisplay from '../components/SceneDisplay'; | |
| import SessionTransition from '../components/SessionTransition'; | ||
| import OnboardingFlow, { type OnboardingStage } from '../components/OnboardingFlow'; | ||
| import BGMPlayer from '../components/BGMPlayer'; | ||
| import ChatPanel, { type ChatMessage } from '../components/ChatPanel'; | ||
| import StatusHUD from '../components/StatusHUD'; | ||
| import ActionsHUD from '../components/ActionsHUD'; | ||
| import type { YouTubeVideo } from '../components/YouTubeGrid'; | ||
| import type { Highlight } from '../components/HighlightCard'; | ||
|
|
||
| type TransitionPhase = 'idle' | 'transitioning' | 'ready'; | ||
|
|
||
| const CONNECTION_COLORS: Record<string, string> = { | ||
| connected: '#4ade80', | ||
| connecting: '#fbbf24', | ||
| disconnected: '#ef4444', | ||
| error: '#ef4444', | ||
| }; | ||
|
|
||
| export default function Home() { | ||
| const [started, setStarted] = useState(false); | ||
| const [previewSrc, setPreviewSrc] = useState<string | null>(null); | ||
| const [finalSrc, setFinalSrc] = useState<string | null>(null); | ||
| const [transition, setTransition] = useState<TransitionPhase>('idle'); | ||
| const [transcript, setTranscript] = useState<string>(''); | ||
| const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]); | ||
| const pendingMsgRef = useRef<{ model: string | null; user: string | null }>({ model: null, user: null }); | ||
| const msgIdRef = useRef(0); | ||
| const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
|
||
| // Onboarding state | ||
|
|
@@ -38,7 +36,7 @@ export default function Home() { | |
| const [analysisPercent, setAnalysisPercent] = useState(0); | ||
| const [bgmUrl, setBgmUrl] = useState<string | null>(null); | ||
|
|
||
| const { initAudioContext, playPCM, cleanup: cleanupAudio } = useAudio(); | ||
| const { initAudioContext, playPCM, isPlaying, cleanup: cleanupAudio } = useAudio(); | ||
| const mic = useMicrophone(); | ||
|
|
||
| const handleMessage = useCallback((msg: ServerMessage) => { | ||
|
|
@@ -60,9 +58,48 @@ export default function Home() { | |
| setTransition('ready'); | ||
| setOnboardingStage('reunion'); | ||
| break; | ||
| case 'transcript': | ||
| setTranscript(stripMarkdown(msg.text)); | ||
| case 'transcript': { | ||
| const role = (msg as { role: string }).role as 'model' | 'user'; | ||
| const text = stripMarkdown(msg.text); | ||
| const finished = (msg as { finished?: boolean }).finished ?? false; | ||
|
|
||
| if (finished) { | ||
| // Finalize: flush pending partial text into a completed message. | ||
| const pending = pendingMsgRef.current[role]; | ||
| const finalText = pending ? pending + text : text; | ||
| pendingMsgRef.current[role] = null; | ||
| if (finalText) { | ||
| const id = String(msgIdRef.current++); | ||
| setChatMessages((prev) => { | ||
| // Remove the in-progress placeholder for this role if present. | ||
| const cleaned = prev.filter( | ||
| (m) => !(m.role === role && !m.finished), | ||
| ); | ||
| return [...cleaned, { id, role, text: finalText, finished: true }]; | ||
| }); | ||
| } else { | ||
| // Empty finalize โ just clean up the placeholder. | ||
| setChatMessages((prev) => | ||
| prev.filter((m) => !(m.role === role && !m.finished)), | ||
| ); | ||
| } | ||
| } else { | ||
|
Comment on lines
+66
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a potential bug in how finished transcript messages are handled. If a |
||
| // Streaming partial: accumulate and show placeholder. | ||
| const accumulated = (pendingMsgRef.current[role] ?? '') + text; | ||
| pendingMsgRef.current[role] = accumulated; | ||
| const id = `pending-${role}`; | ||
| setChatMessages((prev) => { | ||
| const cleaned = prev.filter( | ||
| (m) => !(m.role === role && !m.finished), | ||
| ); | ||
| return [ | ||
| ...cleaned, | ||
| { id, role, text: accumulated, finished: false }, | ||
| ]; | ||
| }); | ||
| } | ||
| break; | ||
| } | ||
| case 'youtube_videos': | ||
| setVideos(msg.videos as YouTubeVideo[]); | ||
| setOnboardingStage('youtube_grid'); | ||
|
|
@@ -143,7 +180,8 @@ export default function Home() { | |
| setPreviewSrc(null); | ||
| setFinalSrc(null); | ||
| setTransition('idle'); | ||
| setTranscript(''); | ||
| setChatMessages([]); | ||
| pendingMsgRef.current = { model: null, user: null }; | ||
| setOnboardingStage('welcome'); | ||
| setVideos([]); | ||
| setPersonCrops([]); | ||
|
|
@@ -371,64 +409,14 @@ export default function Home() { | |
| onSelectPerson={handleSelectPerson} | ||
| /> | ||
|
|
||
| {/* Connection indicator */} | ||
| <div | ||
| style={{ | ||
| position: 'absolute', | ||
| top: '1rem', | ||
| right: '1rem', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: '0.5rem', | ||
| zIndex: 10, | ||
| }} | ||
| > | ||
| <div | ||
| style={{ | ||
| width: 8, | ||
| height: 8, | ||
| borderRadius: '50%', | ||
| background: CONNECTION_COLORS[state] ?? '#ef4444', | ||
| }} | ||
| /> | ||
| <span style={{ fontSize: '0.75rem', color: 'var(--color-muted)' }}> | ||
| {state} | ||
| </span> | ||
| {mic.isRecording && ( | ||
| <div | ||
| style={{ | ||
| width: 8, | ||
| height: 8, | ||
| borderRadius: '50%', | ||
| background: '#ef4444', | ||
| animation: 'pulse 1.5s infinite', | ||
| }} | ||
| title="Microphone active" | ||
| /> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Transcript overlay */} | ||
| {transcript && ( | ||
| <div | ||
| style={{ | ||
| position: 'absolute', | ||
| bottom: '6rem', | ||
| left: '50%', | ||
| transform: 'translateX(-50%)', | ||
| maxWidth: '80%', | ||
| padding: '0.75rem 1.5rem', | ||
| background: 'rgba(0,0,0,0.6)', | ||
| borderRadius: '1rem', | ||
| color: 'var(--color-text)', | ||
| fontSize: '1rem', | ||
| textAlign: 'center', | ||
| zIndex: 10, | ||
| }} | ||
| > | ||
| {transcript} | ||
| </div> | ||
| )} | ||
| <StatusHUD | ||
| connection={state} | ||
| isRecording={mic.isRecording} | ||
| isPlaying={isPlaying} | ||
| sessionState={onboardingStage} | ||
| /> | ||
| <ActionsHUD sessionState={onboardingStage} /> | ||
| <ChatPanel messages={chatMessages} /> | ||
|
|
||
| {/* Stop button */} | ||
| <button | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| 'use client'; | ||
|
|
||
| type ActionsHUDProps = { | ||
| sessionState: string; | ||
| }; | ||
|
|
||
| type ActionItem = { | ||
| label: string; | ||
| hint: string; | ||
| }; | ||
|
|
||
| const ONBOARDING_ACTIONS: ActionItem[] = [ | ||
| { label: 'Talk', hint: 'Tell missless who you miss' }, | ||
| { label: 'Share Video', hint: 'Paste a YouTube link' }, | ||
| ]; | ||
|
|
||
| const REUNION_ACTIONS: ActionItem[] = [ | ||
| { label: 'Talk', hint: 'Have a conversation' }, | ||
| { label: 'Scene', hint: '"Paint me a picture of..."' }, | ||
| { label: 'Music', hint: '"Play something peaceful"' }, | ||
| { label: 'Memory', hint: '"Remember when we..."' }, | ||
| { label: 'Album', hint: '"Save this moment"' }, | ||
| ]; | ||
|
|
||
| export default function ActionsHUD({ sessionState }: ActionsHUDProps) { | ||
| const actions = sessionState === 'reunion' ? REUNION_ACTIONS : ONBOARDING_ACTIONS; | ||
|
|
||
| return ( | ||
| <div | ||
| style={{ | ||
| position: 'absolute', | ||
| top: '1rem', | ||
| right: '1rem', | ||
| background: 'rgba(0,0,0,0.5)', | ||
| backdropFilter: 'blur(12px)', | ||
| borderRadius: '0.75rem', | ||
| padding: '0.625rem 0.875rem', | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| gap: '0.375rem', | ||
| fontSize: '0.75rem', | ||
| color: 'var(--color-text)', | ||
| zIndex: 20, | ||
| minWidth: '140px', | ||
| }} | ||
| > | ||
| <div style={{ fontWeight: 600, fontSize: '0.8125rem', marginBottom: '0.125rem' }}> | ||
| You can... | ||
| </div> | ||
| {actions.map((a) => ( | ||
| <div key={a.label} style={{ display: 'flex', flexDirection: 'column', gap: '0.0625rem' }}> | ||
| <span style={{ fontWeight: 500 }}>{a.label}</span> | ||
| <span style={{ color: 'var(--color-muted)', fontSize: '0.6875rem' }}>{a.hint}</span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleServerContentdrops transcription updates wheneverTextis empty, so aFinished=trueterminal chunk with no text is never forwarded. In that case the frontend never receives the finalize signal it needs to clear/commit the pending bubble (it already has explicit empty-finalize handling), and the backend also skipsAddTranscript("user", ...)because that is only executed on finished events inside this same non-empty guard. This can leave stale/concatenated chat turns and lose user utterances from tool context foranalyze_user.Useful? React with ๐ย / ๐.