WIP push-to-talk Letta chat frontend

redesign chat interface

graham.systems 7ae0d991 a7db3e45

verified
Changed files
+257 -112
src
src-tauri
src
letta
+6 -3
src-tauri/Cargo.toml
··· 28 28 futures-util = "0.3.31" 29 29 tokio = "1.47.1" 30 30 dasp = { version = "0.11.0", features = ["all"] } 31 - keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sync-secret-service"] } 32 - strum = {version = "0.27.2", features = ["derive"] } 31 + keyring = { version = "3.6.3", features = [ 32 + "apple-native", 33 + "windows-native", 34 + "sync-secret-service", 35 + ] } 36 + strum = { version = "0.27.2", features = ["derive"] } 33 37 tauri-plugin-store = "2" 34 38 tauri-plugin-http = "2" 35 39 reqwest = { version = "0.12.23", features = ["json"] } ··· 39 43 tauri-plugin-positioner = "2" 40 44 tauri-plugin-single-instance = "2" 41 45 tauri-plugin-window-state = "2" 42 -
+17 -10
src-tauri/src/letta/mod.rs
··· 101 101 { 102 102 Ok(api_key) => { 103 103 let base_url = self.base_url.lock().await.to_owned(); 104 - let agent_id = self.base_url.lock().await.to_owned(); 104 + let agent_id = self.agent_id.lock().await.to_owned(); 105 + let body = &json!({ 106 + "messages": [ 107 + { 108 + "role": "user", 109 + "content": [{ 110 + "type": "text", 111 + "text": msg, 112 + }] 113 + } 114 + ], 115 + "stream_tokens": true 116 + }); 117 + 118 + println!("body: {:?}", body); 119 + 105 120 let req = self 106 121 .http_client 107 122 .post(format!("{base_url}/v1/agents/{agent_id}/messages/stream")) 108 123 .header("Authorization", format!("Bearer {api_key}")) 109 124 .header("Content-Type", "application/json") 110 - .json(&json!({ 111 - "messages": [ 112 - { 113 - "role": "user", 114 - "content": [ msg ] 115 - } 116 - ], 117 - "stream_tokens": true 118 - })); 125 + .json(body); 119 126 120 127 let mut source = 121 128 EventSource::new(req).expect("could not convert request to event source");
+7
src/app.css
··· 20 20 --color-rose-pine-highlight-high: hsl(248deg, 13%, 36%); 21 21 22 22 --font-sans: "Recursive Variable"; 23 + 24 + --font-cursive: "Recursive Variable"; 25 + --font-cursive--font-variation-settings: "CRSV" 1, "CASL" 1; 26 + 27 + --font-mono: "Recursive Variable"; 28 + --font-mono--font-variation-settings: "MONO" 1; 29 + 23 30 --font-skeleton: "Flow Block"; 24 31 25 32 --animate-wiggle: wiggle 1s ease-in-out infinite;
+10 -10
src/lib/forms/dropdown.svelte
··· 23 23 }: DropdownProps = $props(); 24 24 25 25 let value = $state("loading"); 26 - let err = $state("") 26 + let err = $state(""); 27 27 let isLoading = $derived(!Array.isArray(options)); 28 28 29 29 $effect(() => { 30 30 Promise.allSettled([ 31 31 Promise.resolve(initialOption), 32 - Promise.resolve(options) 32 + Promise.resolve(options), 33 33 ]).then(([init, opts]) => { 34 34 if (init.status === "fulfilled") { 35 - value = init.value 35 + value = init.value; 36 36 } 37 37 38 38 if (opts.status === "rejected") { 39 - err = opts.reason 39 + err = opts.reason; 40 40 } 41 41 42 - isLoading = false 43 - }) 42 + isLoading = false; 43 + }); 44 44 }); 45 45 </script> 46 46 ··· 54 54 bind:value 55 55 class={[ 56 56 "bg-rose-pine-surface px-3 py-2 w-full flex-1 border-1 border-rose-pine-muted/20 rounded-sm focus:ring-2 ring-rose-pine-iris text-rose-pine-text placeholder:text-rose-pine-subtle", 57 - isLoading && "font-skeleton animate-pulse", props.class, 58 - 57 + isLoading && "font-skeleton animate-pulse", 58 + props.class, 59 59 ]} 60 60 onchange={(e) => onSelect(e.currentTarget.value)} 61 61 > ··· 75 75 isLoading 76 76 ? "icon-[svg-spinners--90-ring-with-bg] text-rose-pine-text" 77 77 : value 78 - ? "icon-[tabler--circle-check-filled] text-rose-pine-foam" 79 - : "icon-[tabler--exclamation-circle] text-rose-pine-love" 78 + ? "icon-[tabler--circle-check-filled] text-rose-pine-foam" 79 + : "icon-[tabler--exclamation-circle] text-rose-pine-love", 80 80 ]} 81 81 ></span> 82 82 </div>
+20 -12
src/lib/forms/input.svelte
··· 18 18 let value = $state("loading"); 19 19 let initial = $state("loading"); 20 20 let isDirty = $derived(value !== initial); 21 - let isLoading = $state(typeof initialValue === "object" && "then" in initialValue) 21 + let isLoading = $state( 22 + typeof initialValue === "object" && "then" in initialValue, 23 + ); 22 24 23 25 $effect(() => { 24 26 Promise.resolve(initialValue) 25 - .then(str => {value = str; initial = str; isLoading = false}) 26 - }) 27 + .then((str) => { 28 + value = str; 29 + initial = str; 30 + isLoading = false; 31 + }); 32 + }); 27 33 28 34 async function onsubmit() { 29 - isLoading = true 35 + isLoading = true; 30 36 await Promise.resolve(onSave(value)); 31 - isLoading = false 37 + isLoading = false; 32 38 } 33 39 </script> 34 40 ··· 61 67 </button> 62 68 {:else if required || isLoading} 63 69 <div class="flex items-center justify-center"> 64 - <span class={[ 65 - "size-6", 66 - isLoading 67 - ? "icon-[svg-spinners--90-ring-with-bg] text-rose-pine-text" 68 - : value 70 + <span 71 + class={[ 72 + "size-6", 73 + isLoading 74 + ? "icon-[svg-spinners--90-ring-with-bg] text-rose-pine-text" 75 + : value 69 76 ? "icon-[tabler--circle-check-filled] text-rose-pine-foam" 70 - : "icon-[tabler--exclamation-circle-filled] text-rose-pine-love" 71 - ]}></span> 77 + : "icon-[tabler--exclamation-circle-filled] text-rose-pine-love", 78 + ]} 79 + ></span> 72 80 </div> 73 81 {/if} 74 82 </form>
+2 -2
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import "@fontsource-variable/recursive/full.css"; 3 - import "@fontsource/flow-block/400.css" 3 + import "@fontsource/flow-block/400.css"; 4 4 import "../app.css"; 5 5 6 6 let { children } = $props(); 7 7 </script> 8 8 9 - <div class="flex flex-col-reverse justify-start gap-2 h-full"> 9 + <div class="flex flex-col-reverse justify-start gap-3 h-full overflow-hidden"> 10 10 {@render children()} 11 11 </div>
+190 -72
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { Channel, invoke } from "@tauri-apps/api/core"; 3 - import Warning from "$lib/warning.svelte"; 2 + import { Channel, invoke } from "@tauri-apps/api/core"; 3 + import { flip } from "svelte/animate"; 4 + import { fade, fly } from "svelte/transition"; 4 5 5 6 interface TranscriptionWord { 6 7 word: string; ··· 8 9 end: number; 9 10 } 10 11 12 + interface Message { 13 + from: "user" | "letta" 14 + id: string, 15 + content: string 16 + } 17 + 18 + const textPlaceholder = "Draft your message..."; 19 + const recordingPlaceholder = "Listening..."; 20 + 21 + const testMessages = [ 22 + { 23 + id: "1", 24 + from: "user", 25 + content: "Hey, can you do this thing for me?" 26 + }, 27 + { 28 + id: "2", 29 + from: "letta", 30 + content: "Sure, I'll do that thing. Let me get started." 31 + }, 32 + { 33 + id: "3", 34 + from: "letta", 35 + 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." 36 + } 37 + ] 38 + 11 39 let isRecording = $state(false); 40 + let isDrafting = $state(false); 41 + let isCompleting = $state(false) 42 + let placeholder = $state(textPlaceholder); 12 43 let draft = $state(""); 13 - let history = $state(new Array<string>()); 14 - let keyInvocation = $state( 15 - invoke("has_secret", { name: "cartesia_api_key" }), 16 - ); 44 + let history = $state(new Array<Message>()); 17 45 18 - $effect(() => { 46 + async function handleCompose() { 19 47 if (isRecording) { 48 + // Cancel recording, but don't submit 49 + await invoke("stop_stt") 50 + isRecording = false; 51 + } else if (isDrafting) { 52 + // Clear draft and close dialog 53 + draft = ""; 54 + isDrafting = false; 55 + } else { 56 + // Show textarea without starting STT 57 + placeholder = textPlaceholder; 58 + isDrafting = true; 59 + } 60 + } 61 + 62 + async function handlePushToTalk() { 63 + if (isDrafting) { 64 + if (isRecording) { 65 + await invoke("stop_stt") 66 + isRecording = false; 67 + } 68 + 69 + const id = crypto.randomUUID(); 70 + invoke("start_llm_completion", { message: draft }) 71 + 72 + history.push({ id, from: "user", content: draft }) 73 + 74 + draft = "" 75 + isDrafting = false 76 + placeholder = textPlaceholder 77 + } else if (!isRecording) { 78 + placeholder = recordingPlaceholder 79 + isDrafting = true; 80 + isRecording = true; 81 + 20 82 const onEvent = new Channel<TranscriptionWord>(); 21 83 22 84 onEvent.onmessage = (word) => { ··· 24 86 }; 25 87 26 88 invoke("start_stt", { onEvent }); 27 - } else { 28 - invoke("stop_stt").then(() => { 29 - if (draft) { 30 - history.unshift(draft); 31 - draft = ""; 32 - } 33 - }); 34 89 } 35 - }); 90 + } 91 + 92 + function handleClear() { 93 + history = [] 94 + } 36 95 </script> 37 96 38 - <div 39 - class="p-2 bg-rose-pine-base rounded-md flex items-center justify-between text-xs shadow-xl" 40 - > 41 - <a 42 - href="/settings" 43 - class="flex items-center gap-1 group text-rose-pine-subtle hover:text-rose-pine-iris" 97 + <!-- Push-to-talk, draft input, and nav --> 98 + <div class="flex items-end justify-end gap-2"> 99 + {#if isDrafting} 100 + <div class="grid grid-cols-1 flex-1 min-h-full text-rose-pine-text"> 101 + <textarea 102 + bind:value={draft} 103 + {placeholder} 104 + transition:fly={{ y: 20}} 105 + contenteditable="true" 106 + 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" 107 + >{draft}</textarea> 108 + <p 109 + 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" 110 + aria-hidden="true" 111 + > 112 + {draft} 113 + </p> 114 + </div> 115 + {/if} 116 + <div 117 + class={[ 118 + "col-start-2 grid grid-cols-2 rounded-md border border-rose-pine-muted/20", 119 + ]} 44 120 > 45 - <span class="icon-[tabler--settings-filled] size-4"></span> 46 - <span class="leading-none group-[:hover]:underline"> 47 - Settings 48 - </span> 49 - </a> 50 - </div> 51 - 52 - <div class="bg-rose-pine-base rounded-md p-3 flex gap-3 shadow-xl"> 53 - {#await keyInvocation} 54 - <div class="flex items-center justify-center w-full"> 121 + <button 122 + class={[ 123 + "p-4 col-span-3 flex items-center justify-center transition rounded-t-md border-b-2 border-rose-pine-muted/20", 124 + isDrafting 125 + ? isRecording 126 + ? "text-rose-pine-highlight-low" 127 + : "text-rose-pine-subtle hover:bg-rose-pine-foam hover:text-rose-pine-highlight-low" 128 + : "bg-rose-pine-surface text-rose-pine-subtle hover:bg-rose-pine-love hover:text-rose-pine-highlight-low", 129 + isRecording ? "animate-pulse bg-rose-pine-love" : "bg-rose-pine-surface" 130 + ]} 131 + onclick={handlePushToTalk} 132 + aria-label="push-to-talk" 133 + > 55 134 <span 56 - class="icon-[svg-spinners--180-ring-with-bg] text-rose-pine-muted" 135 + class={[ 136 + "size-6", 137 + isDrafting 138 + ? "icon-[tabler--send]" 139 + : "icon-[tabler--microphone]", 140 + isRecording && "animate-wiggle" 141 + ]} 57 142 ></span> 58 - </div> 59 - {:then hasKey} 60 - {#if hasKey} 61 - <button 143 + </button> 144 + <button 145 + class={[ 146 + "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", 147 + isDrafting 148 + ? isRecording 149 + ? "hover:bg-rose-pine-gold" 150 + : "hover:bg-rose-pine-rose" 151 + : "hover:bg-rose-pine-foam", 152 + ]} 153 + aria-label="compose message" 154 + onclick={handleCompose} 155 + > 156 + <span 62 157 class={[ 63 - "p-4 w-full flex items-center justify-center rounded-2xl transition", 64 - isRecording 65 - ? "bg-rose-pine-rose text-rose-pine-highlight-low" 66 - : "bg-rose-pine-surface text-rose-pine-subtle hover:bg-rose-pine-overlay hover:text-rose-pine-text", 158 + "size-4", 159 + isDrafting 160 + ? isRecording 161 + ? "icon-[tabler--player-pause-filled]" 162 + : "icon-[tabler--trash-x-filled]" 163 + : "icon-[tabler--edit]", 67 164 ]} 68 - onclick={() => isRecording = !isRecording} 69 - aria-label="push-to-talk" 70 - > 71 - {#if isRecording} 72 - <span 73 - class="icon-[tabler--microphone-off] size-6 animate-wiggle" 74 - ></span> 75 - {:else} 76 - <span class="icon-[tabler--microphone] size-6"></span> 77 - {/if} 78 - </button> 79 - {:else} 80 - <Warning> 81 - Set your Cartesia API key in <a href="/settings" class="underline" 82 - >Settings</a> to get started 83 - </Warning> 84 - {/if} 85 - {:catch err} 86 - <p>{err}</p> 87 - {/await} 165 + ></span> 166 + </button> 167 + <button 168 + 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" 169 + aria-label="clear conversation" 170 + disabled={history.length === 0} 171 + onclick={handleClear} 172 + > 173 + <span 174 + class="size-4 icon-[tabler--message-x]" 175 + ></span> 176 + </button> 177 + <a 178 + href="/settings" 179 + 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" 180 + aria-label="settings" 181 + > 182 + <span class="icon-[tabler--settings-filled] size-4"></span> 183 + </a> 184 + </div> 88 185 </div> 89 186 90 - {#if draft} 91 - <div 92 - class="bg-rose-pine-surface text-rose-pine-subtle italic rounded-md p-3 flex gap-3 shadow-xl" 93 - > 94 - {draft} 95 - </div> 96 - {/if} 187 + <!-- Chat history --> 188 + 189 + <div class="grid row-span-full"> 190 + {#each history as message, i (message.id)} 191 + <div 192 + animate:flip 193 + in:fly={{x: message.from === "user" ? 20 : -20 }} 194 + out:fade 195 + class={[ 196 + "flex", 197 + i !== 0 && history[i - 1].from === "letta" && message.from === "letta" ? "mt-1" : "mt-3", 198 + message.from === "user" 199 + ? "pl-6 justify-end" : "pr-6 justify-start", 200 + ]}> 201 + <div class={[ 202 + "py-3 px-4 rounded-t-xl leading-snug border border-rose-pine-muted/20", 203 + message.from === "user" 204 + ? "bg-rose-pine-iris font-cursive text-rose-pine-highlight-low rounded-bl-xl italic font-semibold" 205 + : "bg-rose-pine-base rounded-br-xl" 206 + ]} 207 + > 208 + {message.content} 209 + </div> 210 + </div> 211 + {/each} 97 212 98 - {#each history as text} 99 - <div class="bg-rose-pine-base rounded-md p-3 flex gap-3 shadow-xl"> 100 - {text} 101 - </div> 102 - {/each} 213 + {#if isCompleting} 214 + <div class="flex"> 215 + <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"> 216 + <span class="icon-[svg-spinners--3-dots-move] size-6"></span> 217 + </div> 218 + </div> 219 + {/if} 220 + </div>
+5 -3
src/routes/settings/letta-settings.svelte
··· 36 36 let baseUrl = $state(fetchBaseUrl()); 37 37 let agentId = $state(fetchAgentId()); 38 38 39 - let lettaAgents: Promise<LettaAgentOption[]> = $state(Promise.resolve([])); 39 + let lettaAgents: Promise<LettaAgentOption[]> = $state( 40 + Promise.resolve([]), 41 + ); 40 42 41 43 let allowAgentEdit = $derived.by(async () => { 42 44 const [hasKey, url] = await Promise.all([ ··· 46 48 47 49 const canEdit = Boolean(hasKey && url); 48 50 49 - if (canEdit) lettaAgents = getLettaAgents() 51 + if (canEdit) lettaAgents = getLettaAgents(); 50 52 51 - return canEdit 53 + return canEdit; 52 54 }); 53 55 </script> 54 56