WIP push-to-talk Letta chat frontend
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>