WIP push-to-talk Letta chat frontend
at main 8.3 kB view raw
1<script lang="ts"> 2 import type { LettaCompletionMessage } from "$lib/rust/LettaCompletionMessage"; 3 import { Channel, invoke } from "@tauri-apps/api/core"; 4 import { flip } from "svelte/animate"; 5 import { fade, fly } from "svelte/transition"; 6 7 interface TranscriptionWord { 8 word: string; 9 start: number; 10 end: number; 11 } 12 13 interface Message { 14 from: "user" | "letta" 15 id: string, 16 content: string 17 } 18 19 const textPlaceholder = "Draft your message..."; 20 const recordingPlaceholder = "Listening..."; 21 22 const testMessages = [ 23 { 24 id: "1", 25 from: "user", 26 content: "Hey, can you do this thing for me?" 27 }, 28 { 29 id: "2", 30 from: "letta", 31 content: "Sure, I'll do that thing. Let me get started." 32 }, 33 { 34 id: "3", 35 from: "letta", 36 content: "Alright, here are the results of me doing that thing that you asked me to do. I needed to fill a lot of space to test reflowing, so there are a lot of words in this message." 37 } 38 ] 39 40 let placeholder = $state(textPlaceholder); 41 let isRecording = $state(false); 42 let isDrafting = $state(false); 43 let draft = $state(""); 44 let isCompleting = $state(false) 45 let agentResponse = $state("") 46 let history = $state(new Array<Message>()); 47 48 async function handleCompose() { 49 if (isRecording) { 50 // Cancel recording, but don't submit 51 await invoke("stop_stt") 52 isRecording = false; 53 } else if (isDrafting) { 54 // Clear draft and close dialog 55 draft = ""; 56 isDrafting = false; 57 } else { 58 // Show textarea without starting STT 59 placeholder = textPlaceholder; 60 isDrafting = true; 61 } 62 } 63 64 async function handlePushToTalk() { 65 if (isDrafting) { 66 if (isRecording) { 67 await invoke("stop_stt") 68 isRecording = false; 69 } 70 71 const onEvent = new Channel<LettaCompletionMessage>() 72 73 let lastType = "" 74 onEvent.onmessage = (e) => { 75 if (e.message_type !== lastType && agentResponse) { 76 let content = agentResponse 77 78 agentResponse = "" 79 lastType = e.message_type; 80 81 history.push({ 82 id: crypto.randomUUID(), 83 from: "letta", 84 content 85 }) 86 } else if (!lastType) { 87 lastType = e.message_type 88 } 89 90 switch (e.message_type) { 91 case "assistant_message": 92 agentResponse += e.content.map(c => c.type === "text" ? c.text : "").join("") 93 break; 94 case "reasoning_message": 95 agentResponse += e.reasoning 96 break; 97 default: 98 console.log("unsupported message:", e) 99 } 100 } 101 102 (async function () { 103 isCompleting = true; 104 await invoke("start_llm_completion", { message: draft, onEvent }) 105 isCompleting = false; 106 })(); 107 108 const id = crypto.randomUUID(); 109 history.push({ id, from: "user", content: draft }) 110 111 draft = "" 112 isDrafting = false 113 placeholder = textPlaceholder 114 } else if (!isRecording) { 115 placeholder = recordingPlaceholder 116 isDrafting = true; 117 isRecording = true; 118 119 const onEvent = new Channel<TranscriptionWord>(); 120 121 onEvent.onmessage = (word) => { 122 draft += word.word; 123 }; 124 125 invoke("start_stt", { onEvent }); 126 } 127 } 128 129 function handleClear() { 130 history = [] 131 } 132</script> 133 134<!-- Push-to-talk, draft input, and nav --> 135<div class="flex items-end justify-end gap-2"> 136 {#if isDrafting} 137 <div class="grid grid-cols-1 flex-1 min-h-full text-rose-pine-text"> 138 <textarea 139 bind:value={draft} 140 {placeholder} 141 transition:fly={{ y: 20}} 142 contenteditable="true" 143 class="p-3 col-start-1 col-end-2 row-start-1 row-end-2 font-cursive text-rose-pine-text bg-rose-pine-surface rounded-md border border-rose-pine-muted/20 placeholder-rose-pine-muted focus:ring-0 focus:border-rose-pine-iris -z-10 leading-tight resize-none text-wrap overflow-hidden" 144 >{draft}</textarea> 145 <p 146 class="p-3 font-cursive leading-tight invisible border-2 grid whitespace-pre-wrap col-start-1 col-end-2 row-start-1 row-end-2 wrap-anywhere" 147 aria-hidden="true" 148 > 149 {draft} 150 </p> 151 </div> 152 {/if} 153 <div 154 class={[ 155 "col-start-2 grid grid-cols-2 rounded-md border border-rose-pine-muted/20", 156 ]} 157 > 158 <button 159 class={[ 160 "p-4 col-span-3 flex items-center justify-center transition rounded-t-md border-b-2 border-rose-pine-muted/20", 161 isDrafting 162 ? isRecording 163 ? "text-rose-pine-highlight-low" 164 : "text-rose-pine-subtle hover:bg-rose-pine-foam hover:text-rose-pine-highlight-low" 165 : "bg-rose-pine-surface text-rose-pine-subtle hover:bg-rose-pine-love hover:text-rose-pine-highlight-low", 166 isRecording ? "animate-pulse bg-rose-pine-love" : "bg-rose-pine-surface" 167 ]} 168 onclick={handlePushToTalk} 169 aria-label="push-to-talk" 170 > 171 <span 172 class={[ 173 "size-6", 174 isDrafting 175 ? "icon-[tabler--send]" 176 : "icon-[tabler--microphone]", 177 isRecording && "animate-wiggle" 178 ]} 179 ></span> 180 </button> 181 <button 182 class={[ 183 "p-2 flex items-center justify-center bg-rose-pine-surface text-rose-pine-subtle transition hover:text-rose-pine-highlight-low rounded-bl-md border-r-[1px] border-rose-pine-muted/20", 184 isDrafting 185 ? isRecording 186 ? "hover:bg-rose-pine-gold" 187 : "hover:bg-rose-pine-rose" 188 : "hover:bg-rose-pine-foam", 189 ]} 190 aria-label="compose message" 191 onclick={handleCompose} 192 > 193 <span 194 class={[ 195 "size-4", 196 isDrafting 197 ? isRecording 198 ? "icon-[tabler--player-pause-filled]" 199 : "icon-[tabler--trash-x-filled]" 200 : "icon-[tabler--edit]", 201 ]} 202 ></span> 203 </button> 204 <button 205 class="p-2 flex items-center justify-center bg-rose-pine-surface text-rose-pine-subtle transition border-x-[1px] border-rose-pine-muted/20 hover:text-rose-pine-highlight-low not-disabled:hover:bg-rose-pine-pine disabled:text-rose-pine-muted/50" 206 aria-label="clear conversation" 207 disabled={history.length === 0} 208 onclick={handleClear} 209 > 210 <span 211 class="size-4 icon-[tabler--message-x]" 212 ></span> 213 </button> 214 <a 215 href="/settings" 216 class="p-2 flex items-center justify-center bg-rose-pine-surface text-rose-pine-subtle rounded-br-md transition border-l-[1px] border-rose-pine-muted/20 hover:bg-rose-pine-iris hover:text-rose-pine-highlight-low" 217 aria-label="settings" 218 > 219 <span class="icon-[tabler--settings-filled] size-4"></span> 220 </a> 221 </div> 222</div> 223 224<!-- Chat history --> 225 226<div class="grid row-span-full"> 227 {#each history as message, i (message.id)} 228 <div 229 animate:flip 230 in:fly={{x: message.from === "user" ? 20 : -20 }} 231 out:fade 232 class={[ 233 "flex", 234 i !== 0 && history[i - 1].from === "letta" && message.from === "letta" ? "mt-1" : "mt-3", 235 message.from === "user" 236 ? "pl-6 justify-end" : "pr-6 justify-start", 237 ]}> 238 <div class={[ 239 "py-3 px-4 rounded-t-xl leading-snug border border-rose-pine-muted/20", 240 message.from === "user" 241 ? "bg-rose-pine-iris font-cursive text-rose-pine-highlight-low rounded-bl-xl italic font-semibold" 242 : "bg-rose-pine-base rounded-br-xl" 243 ]} 244 > 245 {message.content} 246 </div> 247 </div> 248 {/each} 249 250 {#if agentResponse && history.length > 0} 251 <div 252 in:fly={{x: -20 }} 253 class={[ 254 "flex", 255 history[history.length - 1].from === "letta" ? "mt-1" : "mt-3", 256 "pr-6 justify-start", 257 ]}> 258 <div class={[ 259 "py-3 px-4 rounded-t-xl leading-snug border border-rose-pine-muted/20", 260 "bg-rose-pine-base rounded-br-xl" 261 ]} 262 > 263 {agentResponse} 264 </div> 265 </div> 266 {/if} 267 268 {#if isCompleting} 269 <div class="flex"> 270 <div class="mt-1 px-3 flex items-center justify-start bg-rose-pine-base text-rose-pine-subtle rounded-t-xl rounded-br-xl"> 271 <span class="icon-[svg-spinners--3-dots-move] size-6"></span> 272 </div> 273 </div> 274 {/if} 275</div>