+6
-3
src-tauri/Cargo.toml
+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
+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
+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
+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
+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
+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
+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
+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