latex-stickies 1.1.0 → 1.2.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/package.json +5 -2
- package/scripts/brand-electron.js +86 -0
- package/src/ai.js +171 -0
- package/src/editor/entry.js +2 -2
- package/src/main.js +95 -6
- package/src/preload.js +8 -1
- package/src/renderer/ghost.js +205 -0
- package/src/renderer/live-editor.js +1 -0
- package/src/renderer/note.css +7 -0
- package/src/renderer/note.html +1 -0
- package/src/renderer/note.js +5 -0
- package/src/renderer/vendor/codemirror.js +11 -11
- package/src/store.js +25 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "latex-stickies",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
|
@@ -74,6 +76,7 @@
|
|
|
74
76
|
"files": [
|
|
75
77
|
"src/",
|
|
76
78
|
"bin/",
|
|
79
|
+
"scripts/brand-electron.js",
|
|
77
80
|
"build/icon.icns",
|
|
78
81
|
"build/icon.png"
|
|
79
82
|
],
|
|
@@ -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,171 @@
|
|
|
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
|
+
module.exports = { status, complete, pickDefault, trimOverlap, tidy, HOST };
|
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';
|
|
@@ -60,7 +60,7 @@ const codeLanguages = [
|
|
|
60
60
|
];
|
|
61
61
|
|
|
62
62
|
window.CM = {
|
|
63
|
-
EditorState, StateField, RangeSetBuilder, Prec,
|
|
63
|
+
EditorState, StateField, StateEffect, RangeSetBuilder, Prec,
|
|
64
64
|
EditorView, keymap, Decoration, WidgetType, ViewPlugin,
|
|
65
65
|
defaultKeymap, history, historyKeymap, indentWithTab,
|
|
66
66
|
markdown, markdownLanguage,
|
package/src/main.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const {
|
|
2
|
-
app, BrowserWindow, ipcMain, Menu, clipboard, ClipboardItem,
|
|
2
|
+
app, BrowserWindow, dialog, ipcMain, Menu, clipboard, ClipboardItem,
|
|
3
|
+
nativeImage, screen, shell,
|
|
3
4
|
} = require('electron');
|
|
5
|
+
const fs = require('fs');
|
|
4
6
|
const path = require('path');
|
|
5
7
|
|
|
6
8
|
// Pin the identity before anything derives a path from it.
|
|
@@ -15,6 +17,7 @@ app.setPath('userData', path.join(app.getPath('appData'), 'latex-stickies'));
|
|
|
15
17
|
|
|
16
18
|
// Required after the path is set: store.js resolves notes.json when it loads.
|
|
17
19
|
const store = require('./store');
|
|
20
|
+
const ai = require('./ai');
|
|
18
21
|
|
|
19
22
|
const ICON = path.join(__dirname, '..', 'build', 'icon.png');
|
|
20
23
|
|
|
@@ -158,7 +161,25 @@ function restoreNotes() {
|
|
|
158
161
|
|
|
159
162
|
const { WELCOME } = require('./welcome');
|
|
160
163
|
|
|
161
|
-
function buildMenu() {
|
|
164
|
+
async function buildMenu() {
|
|
165
|
+
const saved = store.settings();
|
|
166
|
+
// Only ask Ollama when the feature is on: a cold check on every menu build
|
|
167
|
+
// would add a wait to startup for people who never enable it.
|
|
168
|
+
const { available, models } = saved.aiEnabled
|
|
169
|
+
? await ai.status()
|
|
170
|
+
: { available: false, models: [] };
|
|
171
|
+
|
|
172
|
+
const modelItems = models.map((m) => ({
|
|
173
|
+
label: `${m.name} (${(m.size / 1e9).toFixed(1)} GB)`,
|
|
174
|
+
type: 'radio',
|
|
175
|
+
checked: m.name === saved.aiModel,
|
|
176
|
+
click: () => {
|
|
177
|
+
store.saveSettings({ aiModel: m.name });
|
|
178
|
+
buildMenu();
|
|
179
|
+
windows.forEach((w) => w.webContents.send('ai-settings-changed'));
|
|
180
|
+
},
|
|
181
|
+
}));
|
|
182
|
+
|
|
162
183
|
const template = [
|
|
163
184
|
{
|
|
164
185
|
label: app.name,
|
|
@@ -200,6 +221,46 @@ function buildMenu() {
|
|
|
200
221
|
accelerator: 'CmdOrCtrl+E',
|
|
201
222
|
click: () => BrowserWindow.getFocusedWindow()?.webContents.send('toggle-edit'),
|
|
202
223
|
},
|
|
224
|
+
{
|
|
225
|
+
label: 'Autocomplete (Ollama)',
|
|
226
|
+
type: 'checkbox',
|
|
227
|
+
checked: saved.aiEnabled,
|
|
228
|
+
click: async (item) => {
|
|
229
|
+
if (!item.checked) {
|
|
230
|
+
store.saveSettings({ aiEnabled: false });
|
|
231
|
+
} else {
|
|
232
|
+
const found = await ai.status();
|
|
233
|
+
if (!found.available || !found.models.length) {
|
|
234
|
+
item.checked = false;
|
|
235
|
+
dialog.showMessageBox({
|
|
236
|
+
type: 'info',
|
|
237
|
+
message: 'Ollama is not running',
|
|
238
|
+
detail: 'Autocomplete runs entirely on this machine through '
|
|
239
|
+
+ `Ollama, which was not reachable at ${ai.HOST}.\n\n`
|
|
240
|
+
+ 'Start it with "ollama serve", or install it from '
|
|
241
|
+
+ 'ollama.com and pull a model:\n ollama pull qwen2.5-coder:1.5b',
|
|
242
|
+
buttons: ['OK'],
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
store.saveSettings({
|
|
247
|
+
aiEnabled: true,
|
|
248
|
+
aiModel: saved.aiModel || ai.pickDefault(found.models),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
await buildMenu(); // the model list only appears once it is on
|
|
252
|
+
windows.forEach((w) => w.webContents.send('ai-settings-changed'));
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
label: 'Autocomplete Model',
|
|
257
|
+
// Populated once autocomplete is on and Ollama has answered.
|
|
258
|
+
submenu: modelItems.length
|
|
259
|
+
? modelItems
|
|
260
|
+
: [{ label: available ? 'No usable models' : 'Turn on autocomplete first',
|
|
261
|
+
enabled: false }],
|
|
262
|
+
},
|
|
263
|
+
{ type: 'separator' },
|
|
203
264
|
{
|
|
204
265
|
label: 'Copy Note as Image',
|
|
205
266
|
accelerator: 'CmdOrCtrl+Shift+C',
|
|
@@ -280,18 +341,22 @@ function surfaceNotes() {
|
|
|
280
341
|
|
|
281
342
|
app.on('second-instance', surfaceNotes);
|
|
282
343
|
|
|
283
|
-
app.whenReady().then(() => {
|
|
344
|
+
app.whenReady().then(async () => {
|
|
284
345
|
// Run from npm there is no .app bundle to carry the icon, so macOS would show
|
|
285
346
|
// the generic Electron atom in the Dock. Set it explicitly.
|
|
286
|
-
|
|
347
|
+
// Only when running from source or from npm. A packaged app already carries
|
|
348
|
+
// its icon in the bundle, and build/ is not shipped inside it -- trying
|
|
349
|
+
// anyway logged a missing-file error on every launch of the built app.
|
|
350
|
+
if (!app.isPackaged && process.platform === 'darwin' && app.dock) {
|
|
287
351
|
try {
|
|
288
|
-
|
|
352
|
+
const image = nativeImage.createFromBuffer(fs.readFileSync(ICON));
|
|
353
|
+
if (!image.isEmpty()) app.dock.setIcon(image);
|
|
289
354
|
} catch (err) {
|
|
290
355
|
console.error('could not set dock icon', err);
|
|
291
356
|
}
|
|
292
357
|
}
|
|
293
358
|
|
|
294
|
-
buildMenu();
|
|
359
|
+
await buildMenu();
|
|
295
360
|
restoreNotes();
|
|
296
361
|
|
|
297
362
|
if (SMOKE_CLOSE_MS) {
|
|
@@ -403,6 +468,30 @@ ipcMain.handle('note:openExternal', (_e, url) => openExternal(url));
|
|
|
403
468
|
|
|
404
469
|
ipcMain.handle('note:copyText', (_e, text) => clipboard.writeText(String(text)));
|
|
405
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Whether autocomplete is on, and with which model.
|
|
473
|
+
*
|
|
474
|
+
* Off unless Ollama is actually answering: the feature is optional, and a
|
|
475
|
+
* setting that promises something the machine cannot do is worse than no
|
|
476
|
+
* setting at all.
|
|
477
|
+
*/
|
|
478
|
+
ipcMain.handle('ai:settings', async () => {
|
|
479
|
+
const saved = store.settings();
|
|
480
|
+
if (!saved.aiEnabled) return { enabled: false };
|
|
481
|
+
|
|
482
|
+
const { available, models } = await ai.status();
|
|
483
|
+
if (!available || !models.length) return { enabled: false, reason: 'ollama-unreachable' };
|
|
484
|
+
|
|
485
|
+
// models are { name, size } objects; the setting is a name. Fall back to a
|
|
486
|
+
// sensible pick if the chosen model has been removed.
|
|
487
|
+
const names = models.map((m) => m.name);
|
|
488
|
+
const model = names.includes(saved.aiModel) ? saved.aiModel : ai.pickDefault(models);
|
|
489
|
+
if (model !== saved.aiModel) store.saveSettings({ aiModel: model });
|
|
490
|
+
return { enabled: true, model };
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
ipcMain.handle('ai:complete', (_e, payload) => ai.complete(payload || {}));
|
|
494
|
+
|
|
406
495
|
/**
|
|
407
496
|
* Puts a PNG on the clipboard.
|
|
408
497
|
*
|
package/src/preload.js
CHANGED
|
@@ -29,10 +29,17 @@ contextBridge.exposeInMainWorld('sticky', {
|
|
|
29
29
|
flush: (body) => ipcRenderer.sendSync('note:flush', { id: noteId, body }),
|
|
30
30
|
mathMenu: (payload) => ipcRenderer.invoke('note:mathMenu', payload),
|
|
31
31
|
copyNote: (payload) => ipcRenderer.invoke('note:copyNote', payload),
|
|
32
|
+
|
|
33
|
+
// Optional, local-only autocomplete. Requests are made in the main process:
|
|
34
|
+
// this page cannot reach the network, and should not be able to.
|
|
35
|
+
ai: {
|
|
36
|
+
settings: () => ipcRenderer.invoke('ai:settings'),
|
|
37
|
+
complete: (payload) => ipcRenderer.invoke('ai:complete', payload),
|
|
38
|
+
},
|
|
32
39
|
on: (channel, fn) => {
|
|
33
40
|
const allowed = [
|
|
34
41
|
'toggle-edit', 'request-delete', 'font-size', 'always-on-top-changed',
|
|
35
|
-
'copy-note-image',
|
|
42
|
+
'copy-note-image', 'ai-settings-changed',
|
|
36
43
|
];
|
|
37
44
|
if (!allowed.includes(channel)) return;
|
|
38
45
|
ipcRenderer.on(channel, (_e, payload) => fn(payload));
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline autocomplete: a grey continuation after the caret, accepted with Tab.
|
|
3
|
+
*
|
|
4
|
+
* The suggestion is never inserted into the document until it is accepted. It
|
|
5
|
+
* lives in a state field as a decoration, so nothing typed here can be saved to
|
|
6
|
+
* a note by accident, and dismissing it costs nothing.
|
|
7
|
+
*
|
|
8
|
+
* It asks only after a pause, and only in the quiet cases: no suggestion while
|
|
9
|
+
* text is selected, mid-word, or immediately after another suggestion was
|
|
10
|
+
* dismissed. A notes app that interrupts every keystroke is worse than one that
|
|
11
|
+
* never suggests anything.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Wrapped, because these are classic scripts sharing one global scope: naming
|
|
15
|
+
// the same CodeMirror exports here and in live-editor.js is a redeclaration.
|
|
16
|
+
let ghostCompletion;
|
|
17
|
+
let refreshGhostSettings;
|
|
18
|
+
|
|
19
|
+
(() => {
|
|
20
|
+
// Continue.dev, the closest comparison for local FIM autocomplete, debounces
|
|
21
|
+
// at 250-350ms against a ~1024 token prompt budget. The whole industry target
|
|
22
|
+
// for a completion is around 500ms, so spending all of it waiting before even
|
|
23
|
+
// asking, as an earlier 500ms debounce did, leaves nothing for the model.
|
|
24
|
+
const IDLE_MS = 300;
|
|
25
|
+
// Enough context to say something useful, but low enough that a new note is
|
|
26
|
+
// not silently dead: at 12 characters, typing "hello" in a fresh note produced
|
|
27
|
+
// nothing at all and looked broken.
|
|
28
|
+
const MIN_CONTEXT = 4;
|
|
29
|
+
const PREFIX_CHARS = 2000;
|
|
30
|
+
const SUFFIX_CHARS = 600;
|
|
31
|
+
|
|
32
|
+
let enabled = false;
|
|
33
|
+
let model = '';
|
|
34
|
+
let timer = null;
|
|
35
|
+
let requestId = 0;
|
|
36
|
+
|
|
37
|
+
/** Turns suggestions on or off; called at startup and when the setting changes. */
|
|
38
|
+
refreshGhostSettings = async function refreshGhostSettings() {
|
|
39
|
+
// The bridge may not offer it at all -- an older preload, or the snapshot
|
|
40
|
+
// window. Absent means off, never an error.
|
|
41
|
+
if (!window.sticky || !window.sticky.ai) {
|
|
42
|
+
enabled = false;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const settings = await window.sticky.ai.settings();
|
|
46
|
+
enabled = !!settings.enabled;
|
|
47
|
+
model = settings.model || '';
|
|
48
|
+
return enabled;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/* ---------- the suggestion itself ---------- */
|
|
52
|
+
|
|
53
|
+
const { StateField, StateEffect, EditorView, Decoration, WidgetType, keymap, Prec } = window.CM;
|
|
54
|
+
|
|
55
|
+
const setGhost = StateEffect.define();
|
|
56
|
+
const clearGhost = StateEffect.define();
|
|
57
|
+
|
|
58
|
+
class GhostWidget extends WidgetType {
|
|
59
|
+
constructor(text) {
|
|
60
|
+
super();
|
|
61
|
+
this.text = text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
eq(other) {
|
|
65
|
+
return other.text === this.text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toDOM() {
|
|
69
|
+
const span = document.createElement('span');
|
|
70
|
+
span.className = 'cm-ghost';
|
|
71
|
+
span.textContent = this.text;
|
|
72
|
+
return span;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ignoreEvent() {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ghostField = StateField.define({
|
|
81
|
+
create: () => ({ text: '', pos: -1, deco: Decoration.none }),
|
|
82
|
+
update(value, tr) {
|
|
83
|
+
for (const effect of tr.effects) {
|
|
84
|
+
if (effect.is(clearGhost)) {
|
|
85
|
+
return { text: '', pos: -1, deco: Decoration.none };
|
|
86
|
+
}
|
|
87
|
+
if (effect.is(setGhost)) {
|
|
88
|
+
const { text, pos } = effect.value;
|
|
89
|
+
return {
|
|
90
|
+
text,
|
|
91
|
+
pos,
|
|
92
|
+
deco: Decoration.set([
|
|
93
|
+
Decoration.widget({ widget: new GhostWidget(text), side: 1 }).range(pos),
|
|
94
|
+
]),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Any edit or cursor move invalidates it.
|
|
99
|
+
if (tr.docChanged || tr.selection) {
|
|
100
|
+
return { text: '', pos: -1, deco: Decoration.none };
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
},
|
|
104
|
+
provide: (field) => EditorView.decorations.from(field, (v) => v.deco),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/* ---------- asking ---------- */
|
|
108
|
+
|
|
109
|
+
function shouldAsk(state) {
|
|
110
|
+
const sel = state.selection.main;
|
|
111
|
+
if (!sel.empty) return false; // not while selecting
|
|
112
|
+
if (sel.from < MIN_CONTEXT) return false;
|
|
113
|
+
|
|
114
|
+
// Never inside a word, where the suggestion would sit on top of what is
|
|
115
|
+
// already written. The end of a line, or before a space or punctuation, is
|
|
116
|
+
// fair game -- including mid-line, which is where revising a note happens.
|
|
117
|
+
const after = state.doc.sliceString(sel.from, sel.from + 1);
|
|
118
|
+
return !after || /[\s.,;:!?)\]}$]/.test(after);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The text either side of the caret, cut at line boundaries.
|
|
123
|
+
*
|
|
124
|
+
* Slicing by character count alone lands mid-word, mid-table-row, or inside a
|
|
125
|
+
* code fence whose opening line has been cut away -- the model then has to
|
|
126
|
+
* guess what it is inside. Snapping to whole lines costs nothing and keeps the
|
|
127
|
+
* context syntactically intact.
|
|
128
|
+
*
|
|
129
|
+
* The note's first line comes along when it would otherwise be cut, since in a
|
|
130
|
+
* long note the heading says what the whole thing is about.
|
|
131
|
+
*/
|
|
132
|
+
function contextAround(state, pos) {
|
|
133
|
+
const doc = state.doc;
|
|
134
|
+
|
|
135
|
+
const startLine = doc.lineAt(Math.max(0, pos - PREFIX_CHARS));
|
|
136
|
+
let prefix = doc.sliceString(startLine.from, pos);
|
|
137
|
+
|
|
138
|
+
if (startLine.number > 1) {
|
|
139
|
+
const title = doc.line(1).text.trim();
|
|
140
|
+
// A heading is worth its handful of characters: without it a long note
|
|
141
|
+
// reads to the model as an unlabelled fragment.
|
|
142
|
+
if (title) prefix = `${title}\n...\n${prefix}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const endLine = doc.lineAt(Math.min(doc.length, pos + SUFFIX_CHARS));
|
|
146
|
+
const suffix = doc.sliceString(pos, endLine.to);
|
|
147
|
+
|
|
148
|
+
return { prefix, suffix };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requestGhost(view) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (!enabled) return;
|
|
154
|
+
|
|
155
|
+
timer = setTimeout(async () => {
|
|
156
|
+
if (!shouldAsk(view.state)) return;
|
|
157
|
+
const sel = view.state.selection.main;
|
|
158
|
+
const { prefix, suffix } = contextAround(view.state, sel.from);
|
|
159
|
+
|
|
160
|
+
const id = ++requestId;
|
|
161
|
+
const text = await window.sticky.ai.complete({ prefix, suffix, model });
|
|
162
|
+
|
|
163
|
+
// The caret has moved on, or a newer request overtook this one.
|
|
164
|
+
if (id !== requestId || !text) return;
|
|
165
|
+
if (view.state.selection.main.from !== sel.from) return;
|
|
166
|
+
|
|
167
|
+
view.dispatch({ effects: setGhost.of({ text, pos: sel.from }) });
|
|
168
|
+
}, IDLE_MS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---------- keys ---------- */
|
|
172
|
+
|
|
173
|
+
const acceptGhost = (view) => {
|
|
174
|
+
const ghost = view.state.field(ghostField, false);
|
|
175
|
+
if (!ghost || !ghost.text) return false; // let Tab indent as usual
|
|
176
|
+
view.dispatch({
|
|
177
|
+
changes: { from: ghost.pos, to: ghost.pos, insert: ghost.text },
|
|
178
|
+
selection: { anchor: ghost.pos + ghost.text.length },
|
|
179
|
+
effects: clearGhost.of(null),
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const dismissGhost = (view) => {
|
|
185
|
+
const ghost = view.state.field(ghostField, false);
|
|
186
|
+
if (!ghost || !ghost.text) return false;
|
|
187
|
+
view.dispatch({ effects: clearGhost.of(null) });
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** The extension to hand CodeMirror. */
|
|
192
|
+
ghostCompletion = function ghostCompletion() {
|
|
193
|
+
return [
|
|
194
|
+
ghostField,
|
|
195
|
+
// Ahead of indentWithTab, so Tab takes the suggestion when there is one.
|
|
196
|
+
Prec.highest(keymap.of([
|
|
197
|
+
{ key: 'Tab', run: acceptGhost },
|
|
198
|
+
{ key: 'Escape', run: dismissGhost },
|
|
199
|
+
])),
|
|
200
|
+
EditorView.updateListener.of((update) => {
|
|
201
|
+
if (update.docChanged) requestGhost(update.view);
|
|
202
|
+
}),
|
|
203
|
+
];
|
|
204
|
+
};
|
|
205
|
+
})();
|
|
@@ -588,6 +588,7 @@ function createLiveEditor({ parent, doc, onChange }) {
|
|
|
588
588
|
Prec.high(syntaxHighlighting(markdownStyle)),
|
|
589
589
|
syntaxHighlighting(defaultHighlightStyle), // colours inside code fences
|
|
590
590
|
livePreview,
|
|
591
|
+
ghostCompletion(),
|
|
591
592
|
EditorView.lineWrapping,
|
|
592
593
|
EditorView.updateListener.of((update) => {
|
|
593
594
|
if (update.docChanged) onChange(update.state.doc.toString());
|