Skip to content

Commit bc63965

Browse files
committed
Add initial LLM integration
Squashed commit of the following: commit b461e9d Author: Jacob Strieb <[email protected]> Date: Fri May 2 02:44:19 2025 -0400 Improve LLM implementation and make window openable commit 635e71d Author: Jacob Strieb <[email protected]> Date: Fri May 2 01:25:43 2025 -0400 Edit LLM tool functions and models in the editor commit ced61da Author: Jacob Strieb <[email protected]> Date: Thu May 1 23:01:40 2025 -0400 Fix broken value binding in <Select> commit 28cae7c Author: Jacob Strieb <[email protected]> Date: Tue Apr 29 01:15:22 2025 -0400 Parse code from response commit feefb13 Author: Jacob Strieb <[email protected]> Date: Sun Apr 27 22:44:50 2025 -0400 Get initial Gemini integration working commit 230c13e Author: Jacob Strieb <[email protected]> Date: Sun Apr 27 19:02:51 2025 -0400 Add basic model selection interface commit 4bfe865 Author: Jacob Strieb <[email protected]> Date: Sun Apr 27 13:19:47 2025 -0400 Add custom combo box component commit cb5bf9e Author: Jacob Strieb <[email protected]> Date: Sat Apr 26 17:23:03 2025 -0400 Implement templating for system prompt commit 192efd3 Merge: 62db73d 8f2e6d9 Author: Jacob Strieb <[email protected]> Date: Sat Apr 26 17:21:23 2025 -0400 Merge branch 'master' into llm commit 62db73d Author: Jacob Strieb <[email protected]> Date: Sat Apr 26 02:25:25 2025 -0400 Tighten up LLM query window commit 027b2ad Author: Jacob Strieb <[email protected]> Date: Thu Apr 24 02:44:12 2025 -0400 Restructure LLM component a little commit 72892e7 Author: Jacob Strieb <[email protected]> Date: Thu Apr 24 02:33:44 2025 -0400 Break out LLM window and improve system prompt commit 99820a6 Author: Jacob Strieb <[email protected]> Date: Wed Apr 23 01:42:24 2025 -0400 Begin experimenting with AI prompt development
1 parent 8f2e6d9 commit bc63965

File tree

9 files changed

+344
-8
lines changed

9 files changed

+344
-8
lines changed

TODO.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
- Settings input with numeric input and buttons on either side
3434
- CSP to restrict `worker-src` (except that we don't want to block web
3535
workers, only service workers)
36+
- LLM
37+
- Make LLM conversational -- send messages back and forth to iterate
3638
- Clipboard integration
3739
- Cut
3840
- Fix "put" behavior to copy formulas around to selection

src/App.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
import Details from "./Details.svelte";
108108
import Dialog from "./Dialog.svelte";
109109
import FormulaBar from "./FormulaBar.svelte";
110+
import Llm from "./Llm.svelte";
110111
import SaveLoad from "./SaveLoad.svelte";
111112
import Settings from "./Settings.svelte";
112113
import ShyMenu from "./ShyMenu.svelte";
@@ -157,7 +158,9 @@
157158
}
158159
159160
let dontSave = $state(false);
161+
let saveData = $state();
160162
const save = debounce((data) => {
163+
saveData = data;
161164
imageData = JSON.stringify(data, replaceValues);
162165
if (dontSave) {
163166
dontSave = false;
@@ -320,7 +323,7 @@
320323
bind:open={globals.editorOpen}
321324
style="display: flex; flex-direction: column; align-items: stretch; overflow: hidden; gap: 0.25em;"
322325
>
323-
<CodeEditor bind:editor bind:code={globals.formulaCode} />
326+
<CodeEditor numbers={true} bind:editor bind:code={globals.formulaCode} />
324327
{#if codeError}
325328
<p style="white-space: pre; overflow-x: auto; flex-shrink: 0;">
326329
{codeError}
@@ -400,6 +403,13 @@
400403
</div>
401404
</Dialog>
402405
406+
<Dialog
407+
bind:open={globals.llmOpen}
408+
style="display: flex; flex-direction: column; gap: 1em;"
409+
>
410+
<Llm {globals} />
411+
</Dialog>
412+
403413
{#snippet insertTextButton(text)}
404414
<Button
405415
onpointerdown={(e) => {
@@ -473,6 +483,10 @@
473483
text: "Code Editor",
474484
onclick: () => (globals.editorOpen = !globals.editorOpen),
475485
},
486+
{
487+
text: "Edit with Large Language Models (LLMs)",
488+
onclick: () => (globals.llmOpen = !globals.llmOpen),
489+
},
476490
{
477491
text: "New Spreadsheet",
478492
onclick: () =>

src/CodeEditor.svelte

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929
</style>
3030

3131
<script>
32-
let { editor = $bindable(), code = $bindable(""), ...rest } = $props();
32+
let {
33+
numbers = false,
34+
editor = $bindable(),
35+
code = $bindable(""),
36+
...rest
37+
} = $props();
3338
let lineNumbers = $state();
3439
3540
function indent(s) {
@@ -92,12 +97,14 @@
9297
</script>
9398

9499
<div class="container">
95-
<div class="numbers" bind:this={lineNumbers}>
96-
{#each code.split("\n") as _, i}
97-
<div>{i + 1}</div>
98-
{/each}
99-
<div style="min-height: 5em"></div>
100-
</div>
100+
{#if numbers}
101+
<div class="numbers" bind:this={lineNumbers}>
102+
{#each code.split("\n") as _, i}
103+
<div>{i + 1}</div>
104+
{/each}
105+
<div style="min-height: 5em"></div>
106+
</div>
107+
{/if}
101108
<textarea
102109
bind:this={editor}
103110
bind:value={code}

src/Llm.svelte

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<style>
2+
div,
3+
label {
4+
flex-grow: 1;
5+
display: flex;
6+
flex-direction: column;
7+
}
8+
9+
pre,
10+
textarea {
11+
font-family: monospace, monospace;
12+
border: 1px solid var(--fg-color);
13+
overflow: auto;
14+
white-space: pre-line;
15+
padding: 0.25em;
16+
min-height: 5em;
17+
height: 5em;
18+
flex-grow: 1;
19+
flex-shrink: 1;
20+
resize: vertical;
21+
}
22+
23+
input[type="password"] {
24+
border: 1px solid var(--fg-color);
25+
padding: 0.25em;
26+
}
27+
28+
.row {
29+
display: flex;
30+
flex-direction: row;
31+
flex-wrap: nowrap;
32+
gap: 1ch;
33+
}
34+
35+
.buttons {
36+
flex-shrink: 1;
37+
flex-grow: 0;
38+
display: flex;
39+
flex-direction: row;
40+
flex-wrap: wrap;
41+
justify-content: flex-end;
42+
align-items: center;
43+
gap: 1em;
44+
margin-top: -0.75em;
45+
}
46+
</style>
47+
48+
<script>
49+
import Button from "./Button.svelte";
50+
import Details from "./Details.svelte";
51+
import Select from "./Select.svelte";
52+
53+
// May appear unused, but actually used during evals
54+
import { llmToolFunctions, llmModels } from "./llm.svelte.js";
55+
import { functions as formulaFunctions } from "./formula-functions.svelte";
56+
57+
let { globals } = $props();
58+
let response = $state();
59+
let modelName = $state("Gemini");
60+
let prompt = $state("");
61+
let template =
62+
$state(`You modify a spreadsheet by executing JavaScript code. You only output JavaScript code. You do not output any explanation or comments. You are concise and succinct. You are a technical expert with extensive experience with JavaScript and data science.
63+
64+
You have access to the following functions:
65+
- \${Object.entries(llmToolFunctions).map(([name, f]) => {
66+
const args = f.toString().replaceAll("\\n", " ").replaceAll(/ */g, " ").match(/\\([^)]*\\)/)?.[0] ?? "";
67+
return \`llmToolFunctions.\${name}\${args} \${f.description ?? ""}\`;
68+
}).join("\\n- ")}
69+
70+
Spreadsheet formulas use R1C1 notation (indices start at 0), and support double-quoted strings, integers, floats, booleans, function calls, and arithmetic. Formulas begin with \\\`=\\\` unless they only contain a single number or single string. Formulas can call custom functions defined in JavaScript. Formula functions receive parsed arguments. Cell formatting is handled by formulas (for example the \\\`BOLD\\\` formula will make the cell bold by editing this.style).
71+
72+
Formula functions have access to a \\\`this\\\` object with:
73+
- this.row and this.col - readonly
74+
- this.set(value)
75+
- this.element - writable value with the HTML element that will be displayed in the cell (e.g., buttons, checkboxes, canvas, SVG, etc.)
76+
- this.style - writable value with the CSS style string for the containing \\\`<td>\\\`
77+
78+
The currently available formula functions are all of the JavaScript Math.* functions and: \${Object.keys(formulaFunctions).filter(k => !(k in Math)).join(", ")}.
79+
80+
Available spreadsheets:
81+
\${
82+
globals.sheets.map(
83+
(sheet, i) => \`\${i}. "\${sheet.name}" - \${sheet.heights.length} rows, \${sheet.widths.length} cols\`
84+
).join('\\n')
85+
}
86+
`);
87+
let llmCode = $state("");
88+
89+
// Need to call eval in a separate function from derived.by to ensure globals,
90+
// functions, and prompt are in-scope
91+
function evaluate(t, { formulaFunctions, globals, prompt }) {
92+
return eval(`\`${t}\``);
93+
}
94+
95+
let systemPrompt = $derived.by(() => {
96+
try {
97+
return evaluate(template, { formulaFunctions, globals, prompt });
98+
} catch (e) {
99+
return `Error: ${e?.message ?? e ?? ""}`;
100+
}
101+
});
102+
103+
async function submit() {
104+
response = llmModels[modelName].request(prompt, systemPrompt, {
105+
apiKey: llmModels[modelName].apiKey,
106+
});
107+
// TODO: Fix race condition if the button is pushed multiple times
108+
llmCode = await response;
109+
}
110+
111+
function execute() {
112+
llmToolFunctions.globals = globals;
113+
eval(
114+
llmCode +
115+
// Allows user code to show up in the devtools debugger as "llm-code.js"
116+
"\n//# sourceURL=llm-code.js",
117+
);
118+
delete llmToolFunctions.globals;
119+
}
120+
</script>
121+
122+
<h1>Edit Spreadsheets with Large Language Models ("AI")</h1>
123+
124+
<Details open>
125+
{#snippet summary()}Configure Model{/snippet}
126+
127+
<Select bind:value={modelName}>
128+
{#each Object.keys(llmModels) as model}
129+
<option value={model}>{model}</option>
130+
{/each}
131+
</Select>
132+
<label>
133+
API Key
134+
<div class="row">
135+
<input
136+
type="password"
137+
bind:value={llmModels[modelName].apiKey}
138+
style="flex-grow: 1;"
139+
/>
140+
<!-- TODO -->
141+
<!-- <Button>Save</Button> -->
142+
</div>
143+
</label>
144+
</Details>
145+
146+
<Details>
147+
{#snippet summary()}Configure Prompt{/snippet}
148+
<div>
149+
<p>Template</p>
150+
<textarea bind:value={template}></textarea>
151+
</div>
152+
153+
<div>
154+
<p>System prompt</p>
155+
<pre>{systemPrompt}</pre>
156+
</div>
157+
</Details>
158+
159+
<div>
160+
<p>Prompt</p>
161+
<textarea
162+
class="prompt"
163+
placeholder="Make a simple budget spreadsheet template"
164+
bind:value={prompt}
165+
></textarea>
166+
</div>
167+
168+
<div class="buttons"><Button onclick={submit}>Submit</Button></div>
169+
170+
{#if response}
171+
<h1>LLM Response</h1>
172+
{#await response}
173+
<p>Loading...</p>
174+
{:then}
175+
<textarea bind:value={llmCode} style:min-height="10em" style:flex-grow="2"
176+
></textarea>
177+
<div class="buttons"><Button onclick={execute}>Execute</Button></div>
178+
<!-- TODO: Add error display if evaluated code throws -->
179+
{:catch e}
180+
<p>Error: {e?.message ?? e}</p>
181+
{/await}
182+
{/if}

src/Select.svelte

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<style>
2+
div {
3+
flex-grow: 1;
4+
display: flex;
5+
position: relative;
6+
--padding: 0.25em;
7+
}
8+
9+
select {
10+
width: 100%;
11+
border: 1px solid var(--fg-color);
12+
padding: var(--padding);
13+
cursor: pointer;
14+
}
15+
16+
div::after {
17+
content: "";
18+
position: absolute;
19+
top: var(--padding);
20+
right: var(--padding);
21+
transform: scale(0.75);
22+
}
23+
</style>
24+
25+
<script>
26+
let { children, value = $bindable(), ...rest } = $props();
27+
</script>
28+
29+
<div>
30+
<select bind:value {...rest}>
31+
{@render children?.()}
32+
</select>
33+
</div>

src/classes.svelte.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class State {
2525
helpOpen = $state(false);
2626
editorOpen = $state(false);
2727
imageOpen = $state(false);
28+
llmOpen = $state(false);
2829
formulaCode = $state(`// Examples of user-defined formula functions
2930
3031
functions.factorial = (n) => {

src/formula-functions.svelte.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { debounce } from "./helpers.js";
55
import * as parsers from "./parsers.js";
66
import * as classes from "./classes.svelte.js";
77
import * as compression from "./compress.js";
8+
import { llmToolFunctions, llmModels } from "./llm.svelte.js";
89
import { undefinedArgsToIdentity } from "./helpers.js";
910

1011
export let functions = $state({});
@@ -28,6 +29,12 @@ export function evalCode(code, ret = () => {}) {
2829
try {
2930
throw compression;
3031
} catch {}
32+
try {
33+
throw llmToolFunctions;
34+
} catch {}
35+
try {
36+
throw llmModels;
37+
} catch {}
3138

3239
try {
3340
eval(

src/global.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,11 @@ button {
5858
ul {
5959
margin-left: 1em;
6060
}
61+
62+
input[type="checkbox"] {
63+
appearance: auto;
64+
}
65+
66+
textarea {
67+
overscroll-behavior: auto;
68+
}

0 commit comments

Comments
 (0)