litura-app 0.4.2 → 0.5.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/SPEC.md +32 -26
- package/document-store.js +10 -2
- package/editing.js +27 -0
- package/index.js +111 -2
- package/markdown.js +13 -10
- package/package.json +6 -5
- package/pi.js +9 -0
- package/public/app.js +1207 -881
- package/public/icons/LICENSE +26 -0
- package/public/icons/arrow-up.svg +17 -0
- package/public/icons/arrows-clockwise.svg +16 -0
- package/public/icons/clock-counter-clockwise.svg +16 -0
- package/public/icons/download-simple.svg +17 -0
- package/public/icons/file-plus.svg +18 -0
- package/public/icons/file-text.svg +19 -0
- package/public/icons/gear.svg +16 -0
- package/public/icons/magnifying-glass.svg +16 -0
- package/public/icons/question.svg +1 -0
- package/public/icons/sidebar.svg +16 -0
- package/public/icons/upload-simple.svg +17 -0
- package/public/icons/x.svg +16 -0
- package/public/index.html +80 -29
- package/public/style.css +497 -273
- package/review.js +41 -7
package/SPEC.md
CHANGED
|
@@ -18,33 +18,33 @@ The product does not claim to determine whether text was written by AI. Its revi
|
|
|
18
18
|
## 2. Interface
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
|
|
22
|
-
| [
|
|
23
|
-
|
|
24
|
-
|
|
|
25
|
-
| WORKING DOCUMENT | finding card
|
|
26
|
-
| |
|
|
27
|
-
| CodeMirror editor | finding card
|
|
28
|
-
| (never moves for a card) |
|
|
29
|
-
| highlighted review findings | [< 2 of 3 >] card
|
|
30
|
-
|
|
|
31
|
-
| chat messages and replies
|
|
32
|
-
| [Review draft] reviewing...
|
|
33
|
-
| +-----------------------------------------+
|
|
34
|
-
| | attached-passage chip |
|
|
35
|
-
| | Ask anything... |
|
|
36
|
-
| +-----------------------------------------+
|
|
37
|
-
|
|
21
|
+
+--------+------------------------------------------------------+
|
|
22
|
+
| Files | [sidebar] Style 3 settings |
|
|
23
|
+
| list +------------------------------------------------------+
|
|
24
|
+
| New | |
|
|
25
|
+
| Open… | WORKING DOCUMENT | finding card |
|
|
26
|
+
| Save… | | |
|
|
27
|
+
| History| CodeMirror editor | finding card |
|
|
28
|
+
| | (never moves for a card) | |
|
|
29
|
+
| | highlighted review findings | [< 2 of 3 >] card |
|
|
30
|
+
| | |
|
|
31
|
+
| | chat messages and replies |
|
|
32
|
+
| | [Review draft] reviewing... |
|
|
33
|
+
| | +-----------------------------------------+ |
|
|
34
|
+
| | | attached-passage chip | |
|
|
35
|
+
| | | Ask anything... | |
|
|
36
|
+
| | +-----------------------------------------+ |
|
|
37
|
+
+--------+------------------------------------------------------+
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
The current product has one working-document pane. There is no separate context pane
|
|
40
|
+
The current product has one working-document pane and a file sidebar. There is no separate context pane.
|
|
41
41
|
|
|
42
42
|
### Header
|
|
43
43
|
|
|
44
|
-
One row, and nothing in it spends a model call. Everything about the file
|
|
44
|
+
One row, and nothing in it spends a model call. Everything about the file lives in the sidebar behind the sidebar toggle, and the local score sits at the far end. The header is the frame the draft is read in — it carries no file name and no status line of its own.
|
|
45
45
|
|
|
46
|
-
-
|
|
47
|
-
- Save trouble — a save error, or
|
|
46
|
+
- Sidebar toggle opening the file sidebar: the list of Markdown and text files next to the draft, a filter, and `New`, `Open a file…`, `Save as new…`, `History`. Opening a listed file replaces the workspace draft at once — the click is the intent, and the previous text is kept in History.
|
|
47
|
+
- Save trouble — a save error, or disk writes waiting on an unresolved divergence — is named where the writer is already reading. Routine saves are silent.
|
|
48
48
|
- Local style signals, labelled `Style` with a count of marked passages, when the draft is predominantly Latin script. Hidden otherwise.
|
|
49
49
|
- Settings button.
|
|
50
50
|
|
|
@@ -163,6 +163,8 @@ Editing inside the attached passage ends the attachment and cancels an in-flight
|
|
|
163
163
|
|
|
164
164
|
Clicking any finding shows its card and offers a bubble with `Add to context` and `Suggest rewrites`. It makes no model request: the card carries a single `Options` control, and reading the remark and fixing the sentence by hand is a complete outcome. Clicking the same mark again returns to its card rather than repeating the remark.
|
|
165
165
|
|
|
166
|
+
The bubble is placed inside the writing pane and below the header, never over the sidebar or the fixed header band: a toolbar a chrome layer covers is a toolbar that looks present and swallows the click meant for it. It is anchored to the words it was raised on: a scroll, the panel opening under it or a window resize carries it along with them, and it hides while they are scrolled out of the pane. Only an edit, which may remove those words, takes it away.
|
|
167
|
+
|
|
166
168
|
There is one replacement scope, and it is the quoted passage. The tint in the draft and the range a rewrite would replace are the same span, so a finding never silently rewrites more than it marked, and the card asks for nothing before it can be used. A structural finding names a problem with the paragraph around its quote; its rewrite still replaces the quote, and the whole document goes to the model as context. A change that has to move material between paragraphs is a conversation: typing in the composer with a finding attached opens one.
|
|
167
169
|
|
|
168
170
|
For an ordinary selection, the next composer message becomes the rewrite instruction.
|
|
@@ -189,7 +191,7 @@ The conversation survives a reload. A message sent with a finding attached carri
|
|
|
189
191
|
|
|
190
192
|
### 3.6 Continuation suggestions
|
|
191
193
|
|
|
192
|
-
After
|
|
194
|
+
After 200 milliseconds without typing, a suggestion may be requested when:
|
|
193
195
|
|
|
194
196
|
- the document contains at least 15 non-whitespace characters;
|
|
195
197
|
- the selection is collapsed;
|
|
@@ -199,7 +201,7 @@ After 900 milliseconds without typing, a suggestion may be requested when:
|
|
|
199
201
|
|
|
200
202
|
Only typing (including composition) starts this timer. Paste, deletion, Undo, Redo, loading a document and accepting an AI edit cancel pending suggestions without requesting another one.
|
|
201
203
|
|
|
202
|
-
A failed continuation says so in the
|
|
204
|
+
A failed continuation says so in the notice; the next successful request clears its error. The model may return an empty continuation when the thought is finished or continuing would invent facts or the author's intent. Non-empty output is a single line of at most 15 words; one word or punctuation alone is allowed. The provider returns `NONE`, `TEXT: …` for new words or punctuation, or `WORD: …` for missing letters, so prose quotation marks do not need JSON escaping. The HTTP response still uses `{ suggestion: string }`. Invalid output is reported as an error and never inserted. Spacing is applied at the insertion boundary; repeated tails are removed only at word boundaries. A valid continuation appears in a block below the current line, is announced to assistive technology, and does not enter the document until accepted.
|
|
203
205
|
|
|
204
206
|
- `Tab`: insert the suggestion at its original cursor position.
|
|
205
207
|
- `Escape`: dismiss it and cancel pending work, even before an answer arrives.
|
|
@@ -229,7 +231,7 @@ The settings dialog:
|
|
|
229
231
|
|
|
230
232
|
All settings take effect immediately; there is no Save button.
|
|
231
233
|
|
|
232
|
-
When no model is selected, the review, rewrite, chat, and manual-continuation actions re-check Pi status once, then report the missing setup in the
|
|
234
|
+
When no model is selected, the review, rewrite, chat, and manual-continuation actions re-check Pi status once, then report the missing setup in the notice and open this dialog instead of failing silently. Automatic review and continuation suggestions stay silent and make no request.
|
|
233
235
|
|
|
234
236
|
## 4. State and privacy
|
|
235
237
|
|
|
@@ -256,13 +258,15 @@ Each edit updates the browser working copy and schedules a disk save after 800 m
|
|
|
256
258
|
|
|
257
259
|
At startup, the editor waits for the server to identify and read the draft. It restores this tab's working copy when available, otherwise the document's shared browser copy, otherwise the disk text. An empty browser copy is distinct from a missing one. Legacy unscoped browser text is offered for export, not silently imported into another workspace.
|
|
258
260
|
|
|
259
|
-
If browser and disk differ at startup, or the disk revision changes when the window regains focus or visibility, Litura
|
|
261
|
+
If browser and disk differ at startup, or the disk revision changes when the window regains focus or visibility, Litura never opens a modal. When the browser copy is untouched since the last confirmed save it takes the disk copy silently; when only one side moved there is nothing to decide. Only two genuinely diverged copies raise a slim banner above the draft — `Load disk`, `Keep mine`, and a `What changed` word diff — while the draft stays editable and only disk writes wait. Choosing a copy resumes revision-checked saving; another external change can raise the banner again. Nothing is merged automatically.
|
|
260
262
|
|
|
261
263
|
Save errors remain visible and pause autosave until the writer retries. A failed request does not advance the recorded disk revision. The browser warns before leaving while a save is in flight or the working text differs from the last confirmed disk copy.
|
|
262
264
|
|
|
263
265
|
Findings are restored only when their stored document text exactly matches the loaded draft, then their quotes are re-anchored. Chat restores messages, not finding cards or rewrite previews. Replacing the document clears findings, dismissed findings and chat, and keeps a browser checkpoint of the previous text. Keeping an AI replacement also creates a checkpoint. Rewrite previews alone change neither stored copy.
|
|
264
266
|
|
|
265
|
-
`Cmd/Ctrl+S` requests an immediate disk save, respecting any paused conflict or error. `Export a copy` downloads the current text using the workspace draft's filename. `Open a file…` and drag-and-drop accept `.md`, `.markdown` and `.txt` files up to 1 MiB and
|
|
267
|
+
`Cmd/Ctrl+S` requests an immediate disk save, respecting any paused conflict or error. `Export a copy` downloads the current text using the workspace draft's filename. `Open a file…` and drag-and-drop accept `.md`, `.markdown` and `.txt` files up to 1 MiB and import without asking. Import replaces the current workspace draft's contents; it does not switch the server to the imported file's path. History restores a previous version at once while keeping a checkpoint of the current text. `New`, import and restore never ask first: the replaced text goes to History, and the notice offers `Undo` for a few seconds.
|
|
268
|
+
|
|
269
|
+
Everything the app reports outside the conversation — save and load trouble, model errors, confirmations with `Undo` — appears in one notice at the bottom-left, one at a time. Passing notices fade after six seconds; problems stay until their action or `×`. A chat answer never shows the raw edit list: a response that drops the `<litura-edits>` tags still yields edit cards.
|
|
266
270
|
|
|
267
271
|
Every model-backed action sends the current full document to the selected provider. Rewrite and review requests additionally send the relevant selection or target passages.
|
|
268
272
|
|
|
@@ -343,6 +347,8 @@ All bodies and non-streaming responses are JSON unless noted.
|
|
|
343
347
|
| `GET` | `/draft` | `{ text, path, id, exists, revision }`; a missing file has empty text and revision `missing` |
|
|
344
348
|
| `PUT` | `/draft` | Save `{ text, revision }`; returns `{ saved: true, text, path, id, exists, revision }`, or `409` with `{ error, current }` when the revision differs |
|
|
345
349
|
| `GET` | `/draft/history` | `{ snapshots }`, latest 20 disk backups with `text`, `at` and `revision` |
|
|
350
|
+
| `GET` | `/files` | `{ files }`: Markdown and text files next to the draft, each with `name`, `size`, `mtime` and a markup-stripped `excerpt` (first meaningful prose, ~160 chars) for the card preview, sorted by name |
|
|
351
|
+
| `GET` | `/file?name=` | `{ name, text }` for one workspace file by basename; `400` for anything that is not a plain Markdown or text name, `404` when it is gone, `413` past 1 MB |
|
|
346
352
|
| `GET` | `/api/version` | `{ name, current }`; with `?check=1` also `latest` from the npm registry, or `error` if it is unreachable |
|
|
347
353
|
| `GET` | `/api/agent/status` | Providers, models, auth status, and default selection |
|
|
348
354
|
| `POST` | `/api/agent/credentials` | Save a provider API key through Pi |
|
package/document-store.js
CHANGED
|
@@ -24,8 +24,16 @@ export function documentStore(filename) {
|
|
|
24
24
|
let files;
|
|
25
25
|
try { files = fs.readdirSync(historyDir); }
|
|
26
26
|
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const items = [];
|
|
28
|
+
for (const name of files.filter(name => /^\d+-[a-f0-9-]+\.json$/.test(name)).sort().reverse().slice(0, 20)) {
|
|
29
|
+
try {
|
|
30
|
+
const item = JSON.parse(fs.readFileSync(path.join(historyDir, name), 'utf8'));
|
|
31
|
+
if (item && typeof item.text === 'string' && typeof item.at === 'string' && typeof item.revision === 'string') items.push(item);
|
|
32
|
+
} catch {
|
|
33
|
+
// One corrupt snapshot must not hide the rest of the history.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return items;
|
|
29
37
|
}
|
|
30
38
|
function write(text, revision) {
|
|
31
39
|
if (typeof text !== 'string' || typeof revision !== 'string') throw Object.assign(new Error('text and revision are required strings'), { status: 400 });
|
package/editing.js
CHANGED
|
@@ -62,3 +62,30 @@ function coarseDiff(a, b) {
|
|
|
62
62
|
{ type: 'keep', text: end ? a.slice(-end).join('') : '' },
|
|
63
63
|
].filter(op => op.text);
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
// Whose turn it is when the disk copy moved under the editor. lastDisk is
|
|
67
|
+
// the text we last confirmed on disk (durable across restarts); local is
|
|
68
|
+
// the editor now, disk is freshly read. No merge exists, only a verdict:
|
|
69
|
+
// - 'in-sync': nothing to do, adopt the fresh revision quietly.
|
|
70
|
+
// - 'fast-forward': the writer changed nothing since the last save — take
|
|
71
|
+
// the disk copy silently, no question asked.
|
|
72
|
+
// - 'diverged': both sides moved since the baseline — ask, but never modal.
|
|
73
|
+
// An unknown baseline (legacy installs) can only answer 'diverged': without
|
|
74
|
+
// proof the writer stood still, silence would discard their work.
|
|
75
|
+
export function resolveDrift(local, disk, lastDisk) {
|
|
76
|
+
if (local === disk) return 'in-sync';
|
|
77
|
+
if (lastDisk !== undefined && local === lastDisk) return 'fast-forward';
|
|
78
|
+
return 'diverged';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Mark bubble box inside `area` (the writing pane below the header). Above
|
|
82
|
+
// the line, clear of its words; below with a gap when the header leaves no
|
|
83
|
+
// room above; pinned inside when the pane is narrower than the bubble.
|
|
84
|
+
export function clampBubble(x, y, size, area) {
|
|
85
|
+
const width = Math.min(size.w, area.right - area.left);
|
|
86
|
+
const left = Math.max(area.left, Math.min(x - width / 2, area.right - width));
|
|
87
|
+
let top = y - size.h - 4;
|
|
88
|
+
if (top < area.top) top = y + 20;
|
|
89
|
+
if (top + size.h > area.bottom) top = Math.max(area.top, area.bottom - size.h);
|
|
90
|
+
return { left, top };
|
|
91
|
+
}
|
package/index.js
CHANGED
|
@@ -108,6 +108,60 @@ const STYLE_FILE = process.env.STYLE_FILE
|
|
|
108
108
|
|| [path.join(CWD, 'style.md'), path.join(ROOT, 'style.md')].find(p => fs.existsSync(p))
|
|
109
109
|
|| path.join(CWD, 'style.md');
|
|
110
110
|
|
|
111
|
+
// ─── Workspace files ───────────────────────────────────────────────────────
|
|
112
|
+
// The sidebar lists the Markdown and text files sitting next to the draft,
|
|
113
|
+
// so the writer can pull one into the workspace without leaving the page.
|
|
114
|
+
// The anchor is the draft's own folder, not the process working directory:
|
|
115
|
+
// DRAFT_FILE may point elsewhere, and the listing must follow the draft.
|
|
116
|
+
// Listing and reading only: writing stays with the draft CAS. Names are
|
|
117
|
+
// basenames with an approved extension, resolved inside that folder —
|
|
118
|
+
// anything else is a 400, never a filesystem walk.
|
|
119
|
+
const WORKSPACE_EXTENSIONS = new Set(['.md', '.markdown', '.txt']);
|
|
120
|
+
const FILES_DIR = path.dirname(path.resolve(DRAFT_FILE));
|
|
121
|
+
function workspaceFile(name) {
|
|
122
|
+
if (typeof name !== 'string' || !name || name.includes('\0')) return null;
|
|
123
|
+
if (path.basename(name) !== name) return null;
|
|
124
|
+
if (!WORKSPACE_EXTENSIONS.has(path.extname(name).toLowerCase())) return null;
|
|
125
|
+
const resolved = path.join(FILES_DIR, name);
|
|
126
|
+
if (path.resolve(FILES_DIR, name) !== resolved) return null;
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Two-line card preview: the first ~160 chars of actual prose. Markup is
|
|
131
|
+
// stripped line by line — headings, links, images, tags, comments and
|
|
132
|
+
// frontmatter — so the card never shows `<img …>` or `#` artefacts.
|
|
133
|
+
// Cut on a word boundary; a sliced half-word reads as a typo.
|
|
134
|
+
function cardExcerpt(text) {
|
|
135
|
+
const lines = text.replace(/<!--[\s\S]*?-->/g, ' ').split('\n');
|
|
136
|
+
let inFrontmatter = false;
|
|
137
|
+
const kept = [];
|
|
138
|
+
let length = 0;
|
|
139
|
+
for (let i = 0; i < lines.length && length < 200; i++) {
|
|
140
|
+
const raw = lines[i].trim();
|
|
141
|
+
if (i === 0 && /^---$/.test(raw)) { inFrontmatter = true; continue; }
|
|
142
|
+
if (inFrontmatter) { if (/^(---|\.\.\.)$/.test(raw)) inFrontmatter = false; continue; }
|
|
143
|
+
const line = raw
|
|
144
|
+
.replace(/<[^>]*>/g, ' ')
|
|
145
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
146
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
147
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
148
|
+
.replace(/(`{1,3})(.*?)\1/g, '$2')
|
|
149
|
+
.replace(/~~(.*?)~~/g, '$1')
|
|
150
|
+
.replace(/^#{1,6}\s+/, '')
|
|
151
|
+
.replace(/^>\s?/, '')
|
|
152
|
+
.replace(/^([-*+]\s+|\d+[.)]\s+)/, '')
|
|
153
|
+
.replace(/[*_~`|]+/g, '')
|
|
154
|
+
.replace(/\s+/g, ' ')
|
|
155
|
+
.trim();
|
|
156
|
+
if (!line || /^[-*_]{3,}$/.test(line)) continue;
|
|
157
|
+
kept.push(line);
|
|
158
|
+
length += line.length + 1;
|
|
159
|
+
}
|
|
160
|
+
const joined = kept.join(' ');
|
|
161
|
+
if (joined.length <= 160) return joined;
|
|
162
|
+
return joined.slice(0, 160).replace(/\s+\S*$/, '').trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
111
165
|
// ─── Style guide ───────────────────────────────────────────────────────────
|
|
112
166
|
let styleWarnedOnce = false;
|
|
113
167
|
|
|
@@ -237,11 +291,21 @@ function readBody(req) {
|
|
|
237
291
|
});
|
|
238
292
|
}
|
|
239
293
|
|
|
294
|
+
// macOS gates Documents, Desktop and Downloads behind a per-app consent
|
|
295
|
+
// prompt; a past "Don't Allow" surfaces as EPERM on every open, with no
|
|
296
|
+
// re-prompt. Name the fix, not the errno.
|
|
297
|
+
function fsErrorMessage(error) {
|
|
298
|
+
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
|
299
|
+
return 'Litura may not open this folder. On macOS allow it under System Settings → Privacy & Security → Files and Folders, or pick a folder outside Documents, Desktop and Downloads.';
|
|
300
|
+
}
|
|
301
|
+
return error?.message || String(error);
|
|
302
|
+
}
|
|
303
|
+
|
|
240
304
|
// ─── Server ────────────────────────────────────────────────────────────────
|
|
241
305
|
const server = http.createServer((req, res) => {
|
|
242
306
|
handleRequest(req, res).catch(error => {
|
|
243
307
|
console.error('[request]', error.message);
|
|
244
|
-
if (!res.headersSent) sendJson(res, error.status || 500, { error: error
|
|
308
|
+
if (!res.headersSent) sendJson(res, error.status || 500, { error: fsErrorMessage(error) });
|
|
245
309
|
else res.end();
|
|
246
310
|
});
|
|
247
311
|
});
|
|
@@ -313,13 +377,58 @@ async function handleRequest(req, res) {
|
|
|
313
377
|
sendJson(res, 200, { saved: true, ...draftStore.write(body.text, body.revision) });
|
|
314
378
|
} catch (error) {
|
|
315
379
|
console.error('[/draft]', error.message);
|
|
316
|
-
sendJson(res, error.status || 500, { error: error
|
|
380
|
+
sendJson(res, error.status || 500, { error: fsErrorMessage(error), current: error.current });
|
|
317
381
|
}
|
|
318
382
|
return;
|
|
319
383
|
}
|
|
320
384
|
}
|
|
321
385
|
if (req.method === 'GET' && url.pathname === '/draft/history') return sendJson(res, 200, { snapshots: draftStore.history() });
|
|
322
386
|
|
|
387
|
+
if (req.method === 'GET' && url.pathname === '/files') {
|
|
388
|
+
let entries;
|
|
389
|
+
try { entries = fs.readdirSync(FILES_DIR, { withFileTypes: true }); }
|
|
390
|
+
catch (error) { sendJson(res, 500, { error: fsErrorMessage(error) }); return; }
|
|
391
|
+
const files = [];
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
if (!entry.isFile() || entry.name.startsWith('.')) continue;
|
|
394
|
+
if (!WORKSPACE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
|
|
395
|
+
try {
|
|
396
|
+
const full = path.join(FILES_DIR, entry.name);
|
|
397
|
+
const stats = fs.statSync(full);
|
|
398
|
+
// Card excerpt: first meaningful text, not raw markup. Comments,
|
|
399
|
+
// tags, images and markdown markers never reach the two-line preview.
|
|
400
|
+
// Read capped — a 1 MB draft must not be fully read for a preview.
|
|
401
|
+
let excerpt = '';
|
|
402
|
+
try {
|
|
403
|
+
const fd = fs.openSync(full, 'r');
|
|
404
|
+
try {
|
|
405
|
+
const buf = Buffer.alloc(2048);
|
|
406
|
+
const n = fs.readSync(fd, buf, 0, 2048, 0);
|
|
407
|
+
excerpt = cardExcerpt(buf.subarray(0, n).toString('utf8').replace(/\uFFFD+$/g, ''));
|
|
408
|
+
} finally { fs.closeSync(fd); }
|
|
409
|
+
} catch { excerpt = ''; }
|
|
410
|
+
files.push({ name: entry.name, size: stats.size, mtime: stats.mtime.toISOString(), excerpt });
|
|
411
|
+
} catch { /* raced deletion — skip */ }
|
|
412
|
+
}
|
|
413
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
414
|
+
sendJson(res, 200, { files });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (req.method === 'GET' && url.pathname === '/file') {
|
|
418
|
+
const file = workspaceFile(url.searchParams.get('name'));
|
|
419
|
+
if (!file) { sendJson(res, 400, { error: 'Open a Markdown or text file by name.' }); return; }
|
|
420
|
+
let text;
|
|
421
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
422
|
+
catch (error) {
|
|
423
|
+
const missing = error.code === 'ENOENT';
|
|
424
|
+
sendJson(res, missing ? 404 : 500, { error: missing ? 'File no longer exists.' : fsErrorMessage(error) });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (Buffer.byteLength(text) > MAX_DOCUMENT_BYTES) { sendJson(res, 413, { error: 'File exceeds the 1 MB editing limit.' }); return; }
|
|
428
|
+
sendJson(res, 200, { name: path.basename(file), text });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
323
432
|
// ── Static
|
|
324
433
|
if (req.method === 'GET') {
|
|
325
434
|
if (url.pathname === '/') serveStatic(res, path.join(PUBLIC, 'index.html'));
|
package/markdown.js
CHANGED
|
@@ -12,10 +12,9 @@ const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": '&
|
|
|
12
12
|
const escapeHtml = text => text.replace(/[&<>"']/g, char => ESCAPES[char]);
|
|
13
13
|
|
|
14
14
|
// NUL cannot appear in model output and survives escaping, so a stashed code
|
|
15
|
-
// block never collides with ordinary prose like "I have 3 apples".
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const IS_PLACEHOLDER = new RegExp(`^${MARK}\\d+${MARK}$`);
|
|
15
|
+
// block never collides with ordinary prose like "I have 3 apples". If a model
|
|
16
|
+
// ever does emit one, fall back to a private-use character that is absent.
|
|
17
|
+
const MARKS = ['\u0000', '\uE000', '\uE001', '\uE002'];
|
|
19
18
|
|
|
20
19
|
function inline(text) {
|
|
21
20
|
return text
|
|
@@ -29,9 +28,9 @@ function inline(text) {
|
|
|
29
28
|
.replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, '$1<em>$2</em>');
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
function block(text) {
|
|
31
|
+
function block(text, isPlaceholder) {
|
|
33
32
|
if (!text) return '';
|
|
34
|
-
if (
|
|
33
|
+
if (isPlaceholder.test(text)) return text; // a fenced block stands alone
|
|
35
34
|
|
|
36
35
|
const lines = text.split('\n');
|
|
37
36
|
if (lines.every(line => /^\s*[-*+]\s+/.test(line))) {
|
|
@@ -47,17 +46,21 @@ function block(text) {
|
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
export function renderMarkdown(source) {
|
|
49
|
+
const raw = String(source ?? '');
|
|
50
|
+
const mark = MARKS.find(candidate => !raw.includes(candidate)) ?? MARKS[0];
|
|
50
51
|
const stash = [];
|
|
51
|
-
const keep = html => `${
|
|
52
|
+
const keep = html => `${mark}${stash.push(html) - 1}${mark}`;
|
|
53
|
+
const isPlaceholder = new RegExp(`^${mark}\\d+${mark}$`);
|
|
54
|
+
const placeholder = new RegExp(`${mark}(\\d+)${mark}`, 'g');
|
|
52
55
|
|
|
53
56
|
// Lift code out before anything else, so emphasis rules never run inside it.
|
|
54
|
-
const text = escapeHtml(
|
|
57
|
+
const text = escapeHtml(raw)
|
|
55
58
|
.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, body) => keep(`<pre><code>${body.replace(/\n$/, '')}</code></pre>`))
|
|
56
59
|
.replace(/`([^`\n]+)`/g, (_, body) => keep(`<code>${body}</code>`));
|
|
57
60
|
|
|
58
61
|
return text
|
|
59
62
|
.split(/\n{2,}/)
|
|
60
|
-
.map(part => block(part.trim()))
|
|
63
|
+
.map(part => block(part.trim(), isPlaceholder))
|
|
61
64
|
.join('')
|
|
62
|
-
.replace(
|
|
65
|
+
.replace(placeholder, (_, index) => stash[Number(index)]);
|
|
63
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "litura-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A local AI-assisted editor for sharper, more human writing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"writing",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"start": "node index.js",
|
|
46
46
|
"build": "esbuild src/app.js --bundle --outfile=public/app.js --format=iife",
|
|
47
47
|
"build:server": "node scripts/build-server.mjs",
|
|
48
|
+
"build:artifact": "node scripts/build-artifact.mjs",
|
|
48
49
|
"desktop:prepare": "npm run build && npm run build:server && node scripts/fetch-node.mjs",
|
|
49
50
|
"desktop": "tauri dev",
|
|
50
51
|
"desktop:build": "tauri build",
|
|
@@ -58,14 +59,14 @@
|
|
|
58
59
|
"prepublishOnly": "npm run build"
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
|
-
"@earendil-works/pi-ai": "0.
|
|
62
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
62
|
+
"@earendil-works/pi-ai": "^0.87.1",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.87.1"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
|
-
"@tauri-apps/cli": "^2.
|
|
66
|
+
"@tauri-apps/cli": "^2.11.5",
|
|
66
67
|
"codemirror": "^6.0.1",
|
|
67
68
|
"dictionary-en": "^4.0.0",
|
|
68
|
-
"esbuild": "^0.
|
|
69
|
+
"esbuild": "^0.28.2",
|
|
69
70
|
"nspell": "^2.1.5"
|
|
70
71
|
}
|
|
71
72
|
}
|
package/pi.js
CHANGED
|
@@ -25,7 +25,16 @@ const runtime = () => runtimePromise ??= ModelRuntime.create({ allowModelNetwork
|
|
|
25
25
|
.catch(error => {
|
|
26
26
|
// No network, or a provider that will not answer: the snapshot still works.
|
|
27
27
|
console.warn('[pi] model catalog refresh failed, using the bundled list —', error.message);
|
|
28
|
+
// The in-flight assignment already happened; clear it so a later call
|
|
29
|
+
// retries instead of reusing this rejection.
|
|
30
|
+
runtimePromise = undefined;
|
|
28
31
|
return ModelRuntime.create({ allowModelNetwork: false });
|
|
32
|
+
})
|
|
33
|
+
.catch(error => {
|
|
34
|
+
// Even the offline snapshot can fail. Never park a rejected promise in the
|
|
35
|
+
// cache — the next call must try again.
|
|
36
|
+
runtimePromise = undefined;
|
|
37
|
+
throw error;
|
|
29
38
|
});
|
|
30
39
|
|
|
31
40
|
const requestedThinking = () => THINKING_LEVELS.includes(process.env.PI_THINKING_LEVEL)
|