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 +106 -61
- package/package.json +6 -2
- package/scripts/brand-electron.js +86 -0
- package/src/ai.js +217 -0
- package/src/editor/entry.js +6 -2
- package/src/main.js +284 -18
- package/src/preload.js +13 -2
- package/src/renderer/ghost.js +205 -0
- package/src/renderer/live-editor.js +32 -4
- package/src/renderer/note.css +42 -1
- package/src/renderer/note.html +2 -0
- package/src/renderer/note.js +62 -7
- package/src/renderer/vendor/codemirror.js +22 -21
- package/src/store.js +288 -64
- package/src/welcome.js +2 -1
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$`
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
28
|
-
content, so
|
|
29
|
-
|
|
30
|
-
|
|
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*`, `~~
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
39
|
+
**Code** — fenced blocks are highlighted across eleven languages, labelled with
|
|
40
|
+
the language, and each has a copy button.
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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+
|
|
70
|
-
| `
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 #
|
|
92
|
-
npm run vendor #
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
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.
|
|
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
|
+
};
|
package/src/editor/entry.js
CHANGED
|
@@ -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
|
};
|