latex-stickies 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # LaTeX Stickies
2
2
 
3
- Sticky notes for your desktop that render LaTeX and Markdown.
3
+ Sticky notes for your desktop that render LaTeX and Markdown as you type.
4
4
 
5
- Write `$e^{i\pi} + 1 = 0$` in a note and it renders as soon as you click away.
6
- Notes stay where you put them, in the color you chose, across restarts.
5
+ Write `$e^{i\pi} + 1 = 0$` and it becomes the equation. Click into it and the
6
+ source comes back. There is no edit mode and no preview mode.
7
7
 
8
8
  ```
9
9
  npx latex-stickies
@@ -16,41 +16,63 @@ npm install -g latex-stickies
16
16
  latex-stickies
17
17
  ```
18
18
 
19
- Requires Node 22.12 or newer. The first run downloads the Electron runtime (~230 MB), so
20
- give it a minute; later launches are instant.
19
+ Requires Node 22.12 or newer. The first run downloads the Electron runtime
20
+ (~230 MB), so give it a minute; later launches are instant. On a Mac you can
21
+ download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
22
+ instead and skip Node entirely.
21
23
 
22
24
  ## Writing in a note
23
25
 
24
- Everything renders as you type. Click into a line to see the markdown behind
25
- it; move away and it renders again.
26
+ Everything renders while you write. Put the caret in an element to see the
27
+ markdown behind it; move away and it renders again.
26
28
 
27
- **Math** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
28
- content, so ordinary prose like `costs $5 and $6` stays text rather than turning
29
- into a formula. Broken TeX shows the source underlined in red with the parse
30
- error on hover, instead of blanking the note.
29
+ **Maths** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
30
+ content, so prose like `costs $5 and $6` stays text rather than turning into a
31
+ formula. Broken TeX shows the source underlined in red with the parse error on
32
+ hover, instead of blanking the note.
31
33
 
32
- **Markdown** — headings, `**bold**`, `*italic*`, `~~strikethrough~~`, lists,
33
- task lists, tables, blockquotes, and links (which open in your browser).
34
+ **Markdown** — headings, `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
35
+ quotes, lists, and links that open in your browser. Tables render as tables,
36
+ and their cells render maths. Task list checkboxes are clickable: tick one and
37
+ `[ ]` becomes `[x]` in the file.
34
38
 
35
- **Code** — fenced blocks are highlighted in 20 languages, with the language
36
- labelled and a copy button on hover:
39
+ **Code** — fenced blocks are highlighted across eleven languages, labelled with
40
+ the language, and each has a copy button.
37
41
 
38
- ````
39
- ```python
40
- def gaussian(x, mu, sigma):
41
- return exp(-((x - mu) ** 2) / (2 * sigma ** 2))
42
+ Inside a fence the editor behaves like a code editor: Enter keeps your
43
+ indentation and adds a level after `:` or `{`, Tab and Shift+Tab indent and
44
+ dedent, and brackets and quotes auto-close. Outside one it behaves like a notes
45
+ field — lists continue on Enter, and quotes are left alone so `don't` types
46
+ normally.
47
+
48
+ **Images of your maths** — right-click a rendered equation for *Copy as Image*
49
+ or *Copy LaTeX*. `Cmd+Shift+C` copies the whole note as an image, however long
50
+ it is. Both go straight to the clipboard, ready to paste into Slack or an email.
51
+
52
+ ## Autocomplete (optional, local)
53
+
54
+ A grey suggestion appears after a pause in typing; **Tab** accepts it, **Esc**
55
+ dismisses it. It runs entirely on your machine through
56
+ [Ollama](https://ollama.com) — nothing is sent anywhere — and is **off by
57
+ default**. Turn it on under **Note → Autocomplete**.
58
+
59
+ Pull a small model. This fires on every pause, so speed matters far more than
60
+ size:
61
+
62
+ ```
63
+ ollama pull qwen2.5-coder:1.5b
42
64
  ```
43
- ````
44
65
 
45
- Aliases work as you would expect: `sh`, `shell` and `zsh` all mean bash, `py`
46
- means python, and `tex` means latex. A fence with no language stays plain --
47
- guessing on a two-line snippet is wrong often enough to be worse than nothing.
66
+ Measured on a MacBook: **0.06–0.5s** with that model, against 0.4–3.5s for a
67
+ 14B — and the small one wrote better prose. Choose yours under **Note →
68
+ Autocomplete Model**; installed models are listed with their sizes.
48
69
 
49
- Inside a fence the editor behaves like a code editor: Enter keeps your
50
- indentation and adds a level after `:` or `{`, Tab and Shift+Tab indent and
51
- dedent (including a whole selection), and brackets and quotes auto-close.
52
- Outside a fence it behaves like a notes field instead -- lists continue on
53
- Enter, and quotes are left alone so `don't` types normally.
70
+ It knows what this app is for. `$$\int_0^1 x^2 dx = ` suggests `\frac{1}{3}$$`,
71
+ and `The derivative of $x^2$ is ` suggests `$2x$.`
72
+
73
+ **`Cmd+Shift+M`** turns a description into LaTeX: select *"integral of e to the
74
+ minus x squared from 0 to infinity"* and it becomes
75
+ `$\int_{0}^{\infty} e^{-x^2}\,dx$`.
54
76
 
55
77
  ## Keyboard
56
78
 
@@ -59,58 +81,81 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
59
81
  | | |
60
82
  |---|---|
61
83
  | `Cmd+N` | New note |
62
- | `Cmd+W` | Close note |
63
- | `Cmd+Backspace` | Delete note |
64
- | `Cmd+E` | Toggle edit / preview |
65
- | `Cmd+T` | Keep on top |
66
- | `Cmd+±` | Text size |
84
+ | `Cmd+W` | Close note (it reopens next launch) |
85
+ | `Cmd+Shift+Backspace` | Delete note, permanently |
67
86
  | `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
68
87
  | `Cmd+K` | Link |
69
- | `Cmd+Shift+C` | Copy note as image |
70
- | `Esc` | Leave edit mode |
88
+ | `Cmd+F` | Find in note |
89
+ | `Cmd+Shift+M` | Maths from a description |
90
+ | `Cmd+Shift+C` | Copy note as an image |
91
+ | `Cmd+Shift+O` | Open the notes folder |
92
+ | `Cmd+T` | Keep on top |
93
+ | `Cmd+±` | Text size |
94
+
95
+ On Windows and Linux the note windows have no menu bar, so the **☰** button
96
+ in a note's toolbar opens the same menu. Every command is there.
97
+
98
+ The **All Notes** menu lists every note you have. A tick means it is on screen;
99
+ click to open a note, or click a ticked one to close it. So a note you closed
100
+ is one click away rather than lost until the next launch.
71
101
 
72
102
  ## Where notes live
73
103
 
74
- | | |
75
- |---|---|
76
- | macOS | `~/Library/Application Support/latex-stickies/notes.json` |
77
- | Windows | `%APPDATA%\latex-stickies\notes.json` |
78
- | Linux | `~/.config/latex-stickies/notes.json` |
104
+ One Markdown file per note, in a folder you can open:
105
+
106
+ ```
107
+ ~/Documents/LaTeX Stickies/
108
+ shopping-list.md
109
+ lecture-3.md
110
+ .stickies.json # colours, positions, pinned state
111
+ ```
112
+
113
+ They are ordinary files: grep them, keep the folder in git, sync it through
114
+ Dropbox, or edit a note in another editor — the open note follows the change.
115
+ `Cmd+Shift+O` opens the folder.
79
116
 
80
- Saves are atomic -- written to a temp file, flushed, then renamed over the
81
- target -- so a crash, a force quit, or pulling the power leaves the previous
82
- good file intact rather than a truncated one. Only one instance runs at a time,
83
- because two copies would hold divergent notes in memory and the last to quit
84
- would silently overwrite the other.
117
+ Files are named from the note's first line, and renamed if you retitle it. A
118
+ file you rename yourself keeps its name. Dropping a `.tex` or `.txt` file into
119
+ the folder makes it a note too, and it keeps that extension.
120
+
121
+ Metadata lives in the sidecar index rather than in frontmatter, so the note
122
+ files stay clean — a `.tex` file with YAML at the top would not compile.
123
+
124
+ Saves are atomic: written to a temp file, flushed, then renamed over the
125
+ target, so a crash or force quit leaves the previous file intact rather than a
126
+ truncated one. Only one instance runs at a time, because two copies would hold
127
+ divergent notes and the last to quit would overwrite the other.
128
+
129
+ An existing `notes.json` from an older version is migrated the first time and
130
+ then left in place.
85
131
 
86
132
  ## Development
87
133
 
88
134
  ```
89
135
  npm install
90
136
  npm start # run from source
91
- npm test # 4 suites, 62 assertions
92
- npm run vendor # regenerate the bundled Prism
137
+ npm test # unit suites
138
+ npm run vendor # rebuild the bundled libraries
93
139
  npm run icon # rebuild the app icon from assets/icon-master.png
140
+ npm run dist # build a macOS .app and .dmg
94
141
  ```
95
142
 
96
- Artwork lives in `assets/`: `logo.jpeg` is the original, and `icon-master.png`
97
- is the prepared 1024px master the icon is generated from -- transparent ground,
98
- art filling 824 of the canvas, which is the macOS convention that keeps an icon
99
- at the same visual weight as the rest of the Dock.
100
-
101
- The renderer loads over `file://` under a strict content-security policy, so
102
- KaTeX, marked, DOMPurify and Prism are vendored into `src/renderer/vendor/`
103
- rather than pulled from a CDN. Note text is sanitized before it reaches the
104
- page, and the renderer talks to disk only through a six-function preload bridge.
105
-
106
- To build a macOS `.app` and `.dmg`:
143
+ Beyond the unit tests, several checks drive the real app and run in CI on
144
+ macOS, Windows and Linux:
107
145
 
108
146
  ```
109
- npm run dist
147
+ node scripts/smoke.js # boots, and stays up
148
+ node scripts/smoke.js --lifecycle # closing every note behaves per platform
149
+ node scripts/render-check.js # the first-run note renders correctly
150
+ node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
151
+ node scripts/snapshot-check.js # a long note is captured whole
110
152
  ```
111
153
 
112
- The build is unsigned, so macOS will warn on other machines unless you sign it
113
- with an Apple Developer ID.
154
+ The renderer loads over `file://` under a strict content-security policy, so
155
+ CodeMirror and KaTeX are bundled into `src/renderer/vendor/` rather than pulled
156
+ from a CDN. Note text is never parsed as HTML, and the renderer reaches the
157
+ filesystem and the network only through a narrow preload bridge — Ollama is
158
+ called from the main process, so the page keeps `default-src 'none'`.
114
159
 
115
160
  ## License
116
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -10,7 +10,9 @@
10
10
  "icon": "node scripts/make-icon.js",
11
11
  "dist": "node scripts/build-dmg.js",
12
12
  "install-app": "npm run dist && rm -rf \"/Applications/LaTeX Stickies.app\" && cp -R \"dist/mac-arm64/LaTeX Stickies.app\" /Applications/ && xattr -dr com.apple.quarantine \"/Applications/LaTeX Stickies.app\"",
13
- "prepublishOnly": "npm test"
13
+ "prepublishOnly": "npm test",
14
+ "postinstall": "node scripts/brand-electron.js",
15
+ "brand": "node scripts/brand-electron.js"
14
16
  },
15
17
  "build": {
16
18
  "appId": "com.kevinjusak.latexsticky",
@@ -43,6 +45,7 @@
43
45
  "@codemirror/lang-sql": "^6.10.0",
44
46
  "@codemirror/lang-yaml": "^6.1.3",
45
47
  "@codemirror/language": "^6.12.4",
48
+ "@codemirror/search": "^6.7.2",
46
49
  "@codemirror/state": "^6.7.2",
47
50
  "@codemirror/view": "^6.43.10",
48
51
  "@lezer/highlight": "^1.2.3",
@@ -74,6 +77,7 @@
74
77
  "files": [
75
78
  "src/",
76
79
  "bin/",
80
+ "scripts/brand-electron.js",
77
81
  "build/icon.icns",
78
82
  "build/icon.png"
79
83
  ],
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gives the Electron shell this app's name and icon, on macOS.
4
+ *
5
+ * Run from source or from npm there is no .app bundle of our own, so macOS
6
+ * takes the Dock name and icon from the shell being launched -- which is called
7
+ * "Electron" and wears the atom logo. app.setName() cannot change that: the
8
+ * Dock reads it from the bundle before any of our code runs.
9
+ *
10
+ * Editing Info.plist invalidates the bundle's ad-hoc signature, so it is
11
+ * re-signed afterwards. That matters: an unsigned bundle will not launch on
12
+ * Apple Silicon at all, which is a far worse outcome than a wrong name. If
13
+ * anything here fails, the original Info.plist goes back and the install
14
+ * continues quietly -- a cosmetic nicety must never break someone's install.
15
+ */
16
+ const { execFileSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const NAME = 'LaTeX Stickies';
21
+
22
+ function main() {
23
+ if (process.platform !== 'darwin') return; // only macOS names apps this way
24
+
25
+ let appPath;
26
+ try {
27
+ const binary = require(require.resolve('electron', { paths: [path.join(__dirname, '..')] }));
28
+ // .../Electron.app/Contents/MacOS/Electron -> .../Electron.app
29
+ appPath = path.resolve(path.dirname(binary), '..', '..');
30
+ } catch (_) {
31
+ return; // no runtime installed; nothing to brand
32
+ }
33
+
34
+ const plist = path.join(appPath, 'Contents', 'Info.plist');
35
+ if (!fs.existsSync(plist)) return;
36
+
37
+ const read = (key) => {
38
+ try {
39
+ return execFileSync('plutil', ['-extract', key, 'raw', plist]).toString().trim();
40
+ } catch (_) {
41
+ return '';
42
+ }
43
+ };
44
+
45
+ if (read('CFBundleName') === NAME) return; // already done
46
+
47
+ const backup = fs.readFileSync(plist);
48
+ try {
49
+ for (const key of ['CFBundleName', 'CFBundleDisplayName']) {
50
+ try {
51
+ execFileSync('plutil', ['-replace', key, '-string', NAME, plist]);
52
+ } catch (_) {
53
+ execFileSync('plutil', ['-insert', key, '-string', NAME, plist]);
54
+ }
55
+ }
56
+
57
+ // The bundle icon is what shows before the app can set its own.
58
+ const icon = path.join(__dirname, '..', 'build', 'icon.icns');
59
+ const target = path.join(appPath, 'Contents', 'Resources', 'electron.icns');
60
+ if (fs.existsSync(icon) && fs.existsSync(target)) fs.copyFileSync(icon, target);
61
+
62
+ // Required: the edits above invalidate the ad-hoc signature, and macOS
63
+ // refuses to launch a bundle whose signature does not match its contents.
64
+ execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], {
65
+ stdio: 'ignore',
66
+ });
67
+ execFileSync('codesign', ['--verify', appPath], { stdio: 'ignore' });
68
+
69
+ console.log(`named the Electron shell "${NAME}"`);
70
+ } catch (err) {
71
+ fs.writeFileSync(plist, backup);
72
+ try {
73
+ execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], {
74
+ stdio: 'ignore',
75
+ });
76
+ } catch (_) { /* leave it as found */ }
77
+ console.log(`could not rename the Electron shell (${err.message.split('\n')[0]})`);
78
+ }
79
+ }
80
+
81
+ try {
82
+ main();
83
+ } catch (err) {
84
+ // Never fail an install over the name in the Dock.
85
+ console.log('skipped naming the Electron shell:', err.message.split('\n')[0]);
86
+ }
package/src/ai.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Talks to Ollama, if it happens to be running.
3
+ *
4
+ * Requests are made from the main process rather than the renderer. The note
5
+ * page runs under `default-src 'none'` with no connect-src, so it cannot reach
6
+ * the network at all -- and that is worth keeping. Widening the policy so a
7
+ * page full of untrusted note text can open connections would trade a real
8
+ * guarantee for a convenience.
9
+ *
10
+ * Everything here fails quietly. Ollama is optional: if it is not installed,
11
+ * not running, or slow to answer, the feature simply does not appear.
12
+ */
13
+ const HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
14
+
15
+ const STATUS_TIMEOUT_MS = 1500;
16
+ const COMPLETE_TIMEOUT_MS = 8000;
17
+
18
+ /** In-flight completion, so a new keystroke can cancel the last request. */
19
+ let pending = null;
20
+
21
+ async function withTimeout(url, options, ms) {
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), ms);
24
+ try {
25
+ return await fetch(url, { ...options, signal: controller.signal });
26
+ } finally {
27
+ clearTimeout(timer);
28
+ }
29
+ }
30
+
31
+ /** Is Ollama reachable, and what has it got? */
32
+ async function status() {
33
+ try {
34
+ const res = await withTimeout(`${HOST}/api/tags`, {}, STATUS_TIMEOUT_MS);
35
+ if (!res.ok) return { available: false, models: [] };
36
+ const data = await res.json();
37
+ const models = (data.models || [])
38
+ // Embedding models cannot generate text; offering one would just fail.
39
+ .filter((m) => !/embed/i.test(m.name))
40
+ .map((m) => ({ name: m.name, size: m.size || 0 }))
41
+ .sort((a, b) => a.size - b.size);
42
+ return { available: true, models, host: HOST };
43
+ } catch (_) {
44
+ return { available: false, models: [] };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Which model to suggest when none has been chosen.
50
+ *
51
+ * Size matters more than quality here: this runs on every pause in typing, and
52
+ * a 27B model answering in four seconds is worse than a small one answering in
53
+ * two hundred milliseconds. Prefer a code-completion model, then the smallest.
54
+ */
55
+ function pickDefault(models) {
56
+ if (!models.length) return '';
57
+ const coder = models.find((m) => /coder|code/i.test(m.name));
58
+ if (coder) return coder.name;
59
+ // Sort here rather than trusting the caller: relying on the list arriving
60
+ // in size order is the kind of assumption that quietly stops being true.
61
+ return [...models].sort((a, b) => (a.size || 0) - (b.size || 0))[0].name;
62
+ }
63
+
64
+ /**
65
+ * Drops any part of the answer that repeats what is already typed.
66
+ *
67
+ * Asked to continue "- milk\n- eggs\n- ", a model will often reply "- bread",
68
+ * which would insert the bullet twice. Trim the longest overlap between the end
69
+ * of the prefix and the start of the completion.
70
+ */
71
+ function trimOverlap(prefix, completion) {
72
+ const tail = prefix.slice(-40);
73
+ for (let k = Math.min(tail.length, completion.length); k > 0; k -= 1) {
74
+ if (completion.slice(0, k) === tail.slice(-k)) return completion.slice(k);
75
+ }
76
+ return completion;
77
+ }
78
+
79
+ /**
80
+ * Models trained to fill a gap, which is exactly what autocomplete is.
81
+ *
82
+ * Asked with an ordinary instruction, an instruct-tuned model answers as an
83
+ * assistant: given "hello my name is " it replied "Qwen." -- its own name.
84
+ * The fill-in-the-middle format asks it to complete the text rather than
85
+ * respond to it.
86
+ */
87
+ const FIM = /coder|starcoder|codellama|codegemma|codestral/i;
88
+
89
+ function buildRequest({ prefix, suffix, model }) {
90
+ if (FIM.test(model)) {
91
+ return {
92
+ prompt: `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix || ''}<|fim_middle|>`,
93
+ stop: ['\n', '<|fim_pad|>', '<|endoftext|>', '<|file_sep|>'],
94
+ };
95
+ }
96
+ // No FIM support: raw mode at least skips the chat template, so the model
97
+ // continues the text instead of replying to it.
98
+ return { prompt: prefix, stop: ['\n'] };
99
+ }
100
+
101
+ /**
102
+ * Tidies a raw completion into something worth showing.
103
+ *
104
+ * Cuts to one line, ends at the first sentence rather than trailing off, and
105
+ * drops a half-finished word when the model simply ran out of budget.
106
+ */
107
+ function tidy(text, prefix, truncated) {
108
+ let out = String(text || '').replace(/^[\r\n]+/, '').split('\n')[0];
109
+ out = trimOverlap(prefix, out);
110
+
111
+ // End at the first sentence. The terminator must be followed by a space or
112
+ // the end, so "3.14" is not mistaken for the end of a sentence.
113
+ const sentence = /^[\s\S]*?[.!?](?=\s|$)/.exec(out);
114
+ if (sentence) out = sentence[0];
115
+
116
+ // Nothing to cut at: cap the length, and drop a half-finished word -- but
117
+ // only when the model actually ran out of budget, which it tells us. Judging
118
+ // that from the text alone throws away perfectly complete last words.
119
+ if (out.length > 90) out = out.slice(0, 90).replace(/\s+\S*$/, '');
120
+ else if (truncated && !/[\s.!?,;:)\]}]$/.test(out)) {
121
+ out = out.replace(/\s+\S*$/, '');
122
+ }
123
+ return out.trimEnd();
124
+ }
125
+
126
+ /**
127
+ * Asks for a continuation of `prefix`.
128
+ *
129
+ * Cancels whatever was already in flight: the caller types faster than the
130
+ * model answers, and a stale suggestion is worse than none.
131
+ */
132
+ async function complete({ prefix, suffix, model }) {
133
+ if (!prefix || !prefix.trim()) return '';
134
+
135
+ if (pending) pending.abort();
136
+ const controller = new AbortController();
137
+ pending = controller;
138
+
139
+ const timer = setTimeout(() => controller.abort(), COMPLETE_TIMEOUT_MS);
140
+ try {
141
+ const { prompt, stop } = buildRequest({ prefix, suffix, model });
142
+ const res = await fetch(`${HOST}/api/generate`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ signal: controller.signal,
146
+ body: JSON.stringify({
147
+ model,
148
+ prompt,
149
+ // Skips the chat template, so the model continues rather than replies.
150
+ raw: true,
151
+ stream: false,
152
+ options: {
153
+ // Short and fairly literal: this is a completion, not an essay.
154
+ num_predict: 24,
155
+ temperature: 0.15,
156
+ stop,
157
+ },
158
+ }),
159
+ });
160
+ if (!res.ok) return '';
161
+ const data = await res.json();
162
+ return tidy(data.response, prefix, data.done_reason === 'length');
163
+ } catch (_) {
164
+ return ''; // aborted, unreachable, or malformed -- all mean "no suggestion"
165
+ } finally {
166
+ clearTimeout(timer);
167
+ if (pending === controller) pending = null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Turns a description of some maths into LaTeX.
173
+ *
174
+ * Unlike autocomplete this is a real instruction, so it goes through the chat
175
+ * template rather than fill-in-the-middle -- the model is being asked a
176
+ * question, not continuing a sentence.
177
+ */
178
+ async function toLatex({ text, model }) {
179
+ if (!text || !text.trim()) return '';
180
+
181
+ const prompt = 'Write the LaTeX for the following, and reply with the LaTeX '
182
+ + 'only -- no explanation, no dollar signs, no code fences.\n\n'
183
+ + `${text.trim()}\n`;
184
+
185
+ try {
186
+ const res = await withTimeout(`${HOST}/api/generate`, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({
190
+ model,
191
+ prompt,
192
+ stream: false,
193
+ options: { num_predict: 120, temperature: 0.1 },
194
+ }),
195
+ }, 20000);
196
+ if (!res.ok) return '';
197
+ const data = await res.json();
198
+
199
+ // Models wrap the answer in fences, dollars or \[ \] however firmly you
200
+ // ask them not to. The caller supplies its own delimiters.
201
+ return String(data.response || '')
202
+ .replace(/```[a-z]*\n?/gi, '')
203
+ .replace(/```/g, '')
204
+ .trim()
205
+ .replace(/^\\\[|\\\]$/g, '')
206
+ .replace(/^\\\(|\\\)$/g, '')
207
+ .trim()
208
+ .replace(/^\$+|\$+$/g, '')
209
+ .trim();
210
+ } catch (_) {
211
+ return '';
212
+ }
213
+ }
214
+
215
+ module.exports = {
216
+ status, complete, toLatex, pickDefault, trimOverlap, tidy, HOST,
217
+ };
@@ -10,7 +10,7 @@
10
10
  * @codemirror/language-data, which loads grammars with dynamic import -- that
11
11
  * would leave the bundle with chunks it cannot fetch over file://.
12
12
  */
13
- import { EditorState, StateField, RangeSetBuilder, Prec } from '@codemirror/state';
13
+ import { EditorState, StateField, StateEffect, RangeSetBuilder, Prec } from '@codemirror/state';
14
14
  import {
15
15
  EditorView, keymap, Decoration, WidgetType, ViewPlugin,
16
16
  } from '@codemirror/view';
@@ -23,6 +23,9 @@ import {
23
23
  LanguageDescription,
24
24
  } from '@codemirror/language';
25
25
  import { tags } from '@lezer/highlight';
26
+ import {
27
+ search, searchKeymap, highlightSelectionMatches, openSearchPanel,
28
+ } from '@codemirror/search';
26
29
 
27
30
  import { python } from '@codemirror/lang-python';
28
31
  import { javascript } from '@codemirror/lang-javascript';
@@ -60,10 +63,11 @@ const codeLanguages = [
60
63
  ];
61
64
 
62
65
  window.CM = {
63
- EditorState, StateField, RangeSetBuilder, Prec,
66
+ EditorState, StateField, StateEffect, RangeSetBuilder, Prec,
64
67
  EditorView, keymap, Decoration, WidgetType, ViewPlugin,
65
68
  defaultKeymap, history, historyKeymap, indentWithTab,
66
69
  markdown, markdownLanguage,
67
70
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
68
71
  tags, codeLanguages,
72
+ search, searchKeymap, highlightSelectionMatches, openSearchPanel,
69
73
  };