papilot 0.8.1 → 0.8.3
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 +4 -2
- package/bin/doctor.mjs +1 -1
- package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
- package/dist/assets/index-0siHHtfz.js +267 -0
- package/dist/assets/index-CxdYm81a.css +1 -0
- package/dist/assets/{math-Bu3xEyrC.js → math-ibBpTslk.js} +1 -1
- package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-CD1kNKYH.js} +1 -1
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/FullPaper-DUgLb01O.js +54 -0
- package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
- package/engine/dist/web/assets/index-DJywe0wU.js +19 -0
- package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
- package/engine/package-lock.json +3390 -0
- package/engine/package.json +50 -0
- package/{vendor/latex-review → engine}/src/core/build.ts +198 -99
- package/{vendor/latex-review → engine}/src/core/git.ts +133 -28
- package/{vendor/latex-review → engine}/src/core/outline.ts +0 -9
- package/engine/src/core/progress.ts +67 -0
- package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
- package/engine/src/locales.ts +47 -0
- package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
- package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
- package/engine/src/render/positions.ts +34 -0
- package/engine/src/render/synctex.ts +188 -0
- package/engine/src/render/synctex.wasm +0 -0
- package/engine/src/render/synctex.wasm.LICENSE +50 -0
- package/{vendor/latex-review → engine}/src/server.ts +5 -3
- package/{vendor/latex-review → engine}/src/types.ts +13 -0
- package/engine/src/web/FullPaper.tsx +318 -0
- package/engine/src/web/i18n.ts +36 -0
- package/engine/src/web/locales/de.json +253 -0
- package/engine/src/web/locales/es.json +253 -0
- package/engine/src/web/locales/fr.json +253 -0
- package/engine/src/web/locales/index.ts +16 -0
- package/engine/src/web/locales/it.json +253 -0
- package/engine/src/web/locales/ja.json +253 -0
- package/engine/src/web/locales/ko.json +253 -0
- package/engine/src/web/locales/pt.json +253 -0
- package/engine/src/web/locales/sv.json +253 -0
- package/engine/src/web/locales/zh-Hant.json +253 -0
- package/engine/src/web/paper.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/references.tsx +1 -1
- package/{vendor/latex-review → engine}/src/web/review.tsx +99 -2
- package/{vendor/latex-review → engine}/src/web/style.css +71 -11
- package/engine/src/web/view-preferences.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
- package/package.json +18 -9
- package/src/conversation.ts +1 -1
- package/src/server/acp.ts +9 -5
- package/src/server/agents.ts +4 -2
- package/src/server/app.ts +23 -0
- package/src/server/changes.ts +12 -3
- package/src/server/imports.ts +69 -25
- package/src/server/main.ts +3 -2
- package/src/server/manuscript.ts +31 -12
- package/src/server/paths.ts +1 -1
- package/src/server/start.ts +3 -3
- package/src/server/store.ts +0 -8
- package/src/server/update-install.ts +6 -3
- package/src/server/updates.ts +55 -23
- package/src/server/workspace.ts +24 -2
- package/src/shared.ts +7 -1
- package/src/skill-metadata.ts +1 -1
- package/vendor/latex-review/package-lock.json +4 -3106
- package/vendor/latex-review/package.json +3 -46
- package/README.zh-CN.md +0 -100
- package/dist/assets/index-BlLyIqT9.js +0 -265
- package/dist/assets/index-DVZtyZvu.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
- package/vendor/latex-review/src/web/i18n.ts +0 -48
- /package/{vendor/latex-review → engine}/LICENSE +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
- /package/{vendor/latex-review → engine}/src/client.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
- /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
- /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
package/src/server/imports.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { PaperImport, Project } from '../shared.ts';
|
|
|
12
12
|
import type { Manuscripts } from './manuscript.ts';
|
|
13
13
|
|
|
14
14
|
const execute = promisify(execFile);
|
|
15
|
+
// These limits apply to uploads, not folders already on this computer.
|
|
15
16
|
const MAX_BYTES = 64 * 1024 * 1024, MAX_FILES = 5000;
|
|
16
17
|
const CONFIG = '.latex-review.json';
|
|
17
18
|
const skip = (name: string) => ['.git', '__macosx', '.ds_store', 'node_modules', '.papilot'].includes(name.toLowerCase()) || name.startsWith('._');
|
|
@@ -33,40 +34,71 @@ function config(root: string): Record<string, unknown> {
|
|
|
33
34
|
return data;
|
|
34
35
|
}
|
|
35
36
|
export function texFiles(root: string) {
|
|
36
|
-
const files: string[] = []
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const files: string[] = [], folders = [''];
|
|
38
|
+
while (folders.length) {
|
|
39
|
+
const folder = folders.pop()!;
|
|
39
40
|
for (const item of fs.readdirSync(path.join(root, folder), { withFileTypes: true })) {
|
|
40
|
-
if (++count > MAX_FILES) throw new Error('This folder has too many files. Select the folder containing just the paper.');
|
|
41
41
|
if (skip(item.name) || ['build', 'out', 'output'].includes(item.name)) continue;
|
|
42
42
|
const file = path.posix.join(folder, item.name);
|
|
43
|
-
if (item.isDirectory())
|
|
43
|
+
if (item.isDirectory()) folders.push(file);
|
|
44
44
|
else if (item.isFile() && /\.tex$/i.test(file)) files.push(file);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
return files.sort();
|
|
48
|
+
}
|
|
49
|
+
function existingTex(root: string, file: unknown): file is string {
|
|
50
|
+
if (typeof file !== 'string' || !/\.tex$/i.test(file)) return false;
|
|
51
|
+
try {
|
|
52
|
+
safeName(file);
|
|
53
|
+
let current = root;
|
|
54
|
+
for (const part of file.split('/')) {
|
|
55
|
+
current = path.join(current, part);
|
|
56
|
+
if (fs.lstatSync(current).isSymbolicLink()) return false;
|
|
57
|
+
}
|
|
58
|
+
return fs.statSync(current).isFile();
|
|
59
|
+
} catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
function sourceHead(file: string) {
|
|
62
|
+
const fd = fs.openSync(file, 'r');
|
|
63
|
+
try {
|
|
64
|
+
const buffer = Buffer.alloc(300000);
|
|
65
|
+
return buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8');
|
|
66
|
+
} finally { fs.closeSync(fd); }
|
|
48
67
|
}
|
|
49
68
|
export async function inspectPaper(root: string) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
let settings: Record<string, unknown> = {}, error: string | undefined;
|
|
70
|
+
try { settings = config(root); } catch (failure) { error = (failure as Error).message; }
|
|
71
|
+
let main = existingTex(root, settings.main) ? settings.main : '', engine = '';
|
|
72
|
+
// Most papers already declare their main file or keep it near the root.
|
|
73
|
+
// Do not enumerate caches, assets and supporting papers just to open them.
|
|
74
|
+
if (!main) {
|
|
75
|
+
try {
|
|
76
|
+
const module = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
|
|
77
|
+
const detected = module.detectMain(root);
|
|
78
|
+
if (existingTex(root, detected)) main = detected;
|
|
79
|
+
} catch { /* Unusual layouts fall back to a deeper search. */ }
|
|
80
|
+
}
|
|
81
|
+
let candidates = main ? [main] : [];
|
|
82
|
+
if (!main) {
|
|
83
|
+
const files = texFiles(root), roots = files.filter(file => {
|
|
84
|
+
const text = sourceHead(path.join(root, file)).replace(/(?<!\\)%[^\n]*/g, '');
|
|
85
|
+
return /\\documentclass\b/.test(text) && /\\begin\s*\{document\}/.test(text);
|
|
86
|
+
});
|
|
87
|
+
main = roots.length === 1 ? roots[0] : '';
|
|
88
|
+
const found = new Set(roots);
|
|
89
|
+
candidates = [...roots, ...files.filter(file => !found.has(file))];
|
|
90
|
+
}
|
|
55
91
|
try {
|
|
56
|
-
const settings = config(root);
|
|
57
|
-
if (typeof settings.main === 'string' && files.includes(settings.main)) main = settings.main;
|
|
58
|
-
const module = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
|
|
59
|
-
if (!main) { try { main = module.detectMain(root); } catch { /* The author chooses among all TeX files. */ } }
|
|
60
92
|
// Engine detection also works before Git initialization by reading the source hints.
|
|
61
93
|
if (main) {
|
|
62
|
-
const text =
|
|
94
|
+
const text = sourceHead(path.join(root, main));
|
|
63
95
|
engine = typeof settings.engine === 'string' ? settings.engine : '';
|
|
64
96
|
const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
|
|
65
97
|
const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
|
|
66
98
|
if (!engine) engine = text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
|
|
67
99
|
}
|
|
68
100
|
} catch (failure) { error = (failure as Error).message; }
|
|
69
|
-
return { candidates
|
|
101
|
+
return { candidates, main, engine: engine || 'pdflatex', error };
|
|
70
102
|
}
|
|
71
103
|
async function repositoryRoot(root: string) {
|
|
72
104
|
let top: string | undefined;
|
|
@@ -117,7 +149,9 @@ export class PaperImports {
|
|
|
117
149
|
private save(value: PaperImport) {
|
|
118
150
|
fs.mkdirSync(path.join(this.directory, 'imports'), { recursive: true, mode: 0o700 });
|
|
119
151
|
const file = path.join(this.directory, 'imports', value.id + '.json');
|
|
120
|
-
|
|
152
|
+
// Progress is live state; a restarted server fails a running import anyway.
|
|
153
|
+
const { progress: _progress, ...stored } = value;
|
|
154
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(stored), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
|
|
121
155
|
this.sessions.set(value.id, value); return { ...value };
|
|
122
156
|
}
|
|
123
157
|
get(id: string) {
|
|
@@ -170,28 +204,38 @@ export class PaperImports {
|
|
|
170
204
|
if (this.active.has(id)) return value;
|
|
171
205
|
if (this.registered(value.path)) throw new Error('This paper is already added. Open it from the paper list.');
|
|
172
206
|
const main = input.main ?? value.main, engine = input.engine ?? value.engine;
|
|
173
|
-
if (
|
|
207
|
+
if (!existingTex(value.path, main)) throw new Error('Choose an existing TeX main file.');
|
|
174
208
|
if (!['pdflatex', 'xelatex', 'lualatex'].includes(engine as string)) throw new Error('Choose a supported LaTeX engine.');
|
|
175
209
|
if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 120)) throw new Error('Enter a paper name under 120 characters.');
|
|
176
210
|
this.active.add(id);
|
|
177
211
|
const previousError = value.error || '';
|
|
178
|
-
Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined });
|
|
212
|
+
Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined, startedAt: Date.now(), progress: undefined });
|
|
179
213
|
this.save(value);
|
|
180
214
|
void (async () => {
|
|
181
215
|
try {
|
|
182
216
|
await repositoryRoot(value.path);
|
|
183
217
|
if (!repair) {
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
// Keep the author's settings, including a source folder that still
|
|
219
|
+
// contains the main file; without one, the whole folder compiles.
|
|
220
|
+
const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
|
|
221
|
+
const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
|
|
222
|
+
const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
|
|
223
|
+
if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
|
|
186
224
|
}
|
|
187
225
|
await prepareRepository(value.path); value.versioned = true;
|
|
188
226
|
if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
|
|
189
227
|
value.phase = 'building'; this.save(value);
|
|
190
|
-
|
|
191
|
-
|
|
228
|
+
// A retry starts a new build without forcing a clean one, so the
|
|
229
|
+
// opened paper finds this PDF in the cache instead of compiling again.
|
|
230
|
+
this.manuscripts.forgetProject('import-' + id);
|
|
231
|
+
let result = await this.manuscripts.state('import-' + id, value.path);
|
|
232
|
+
while (result.build.status === 'building') {
|
|
233
|
+
value.progress = result.build.progress;
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path);
|
|
235
|
+
}
|
|
192
236
|
value.phase = result.build.status === 'ready' ? 'ready' : 'failed'; value.error = result.build.error;
|
|
193
237
|
} catch (error) { value.phase = 'failed'; value.error = (error as Error).message; }
|
|
194
|
-
finally { this.active.delete(id); this.save(value); }
|
|
238
|
+
finally { this.active.delete(id); value.progress = undefined; this.save(value); }
|
|
195
239
|
})();
|
|
196
240
|
return this.get(id);
|
|
197
241
|
}
|
package/src/server/main.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
3
3
|
import { ROOT, DATA } from './paths.ts';
|
|
4
4
|
import { activeInstallation, waitForActivation } from '../../bin/installation.mjs';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const desktop = process.env.PAPILOT_DESKTOP === '1';
|
|
7
|
+
if (!desktop) await waitForActivation(DATA);
|
|
8
|
+
const root = desktop ? ROOT : activeInstallation(ROOT, DATA);
|
|
8
9
|
// A source checkout's default data directory must not move with the release.
|
|
9
10
|
process.env.PAPILOT_DATA = DATA;
|
|
10
11
|
await import(pathToFileURL(path.join(root, 'src/server/start.ts')).href);
|
package/src/server/manuscript.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReviewSnapshot } from './ai-review.ts';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
@@ -8,7 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { pathToFileURL } from 'node:url';
|
|
10
10
|
import { ENGINE } from './paths.ts';
|
|
11
|
-
import type { Annotation, BuildProblem, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
|
|
11
|
+
import type { Annotation, BuildProblem, BuildProgress, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
|
|
12
12
|
import { notesInstructions } from '../notes.ts';
|
|
13
13
|
import { ignorePattern } from '../commit.ts';
|
|
14
14
|
import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
|
|
@@ -223,14 +223,29 @@ async function gitAction(root: string, args: string[], timeout = 60_000) {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
// The preview shows the manuscript as it is on disk, including accepted but
|
|
226
|
-
// uncommitted review changes. A
|
|
227
|
-
// without touching the real index, branches or files.
|
|
226
|
+
// uncommitted review changes. A private index records it as a Git tree
|
|
227
|
+
// without touching the real index, branches or files. It starts from the
|
|
228
|
+
// previous snapshot's index, or the repository's, so Git rehashes only files
|
|
229
|
+
// whose size or modification time changed.
|
|
228
230
|
export async function workingTree(root: string) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
const dir = gitDir(root), saved = dir && path.join(dir, 'latex-review', 'working-index');
|
|
232
|
+
const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-index-')), index = path.join(folder, 'index'), env = { GIT_INDEX_FILE: index };
|
|
233
|
+
const snapshot = async (seed: string | null | undefined) => {
|
|
234
|
+
if (seed && fs.existsSync(seed)) fs.copyFileSync(seed, index);
|
|
235
|
+
else { fs.rmSync(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
|
|
232
236
|
await git(root, ['add', '-A'], env);
|
|
233
237
|
return (await git(root, ['write-tree'], env)).trim();
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
let tree: string;
|
|
241
|
+
try { tree = await snapshot(saved && fs.existsSync(saved) ? saved : dir && path.join(dir, 'index')); }
|
|
242
|
+
catch { tree = await snapshot(null); }
|
|
243
|
+
if (saved) try {
|
|
244
|
+
fs.mkdirSync(path.dirname(saved), { recursive: true });
|
|
245
|
+
const temporary = `${saved}.${randomUUID()}.tmp`;
|
|
246
|
+
fs.copyFileSync(index, temporary); fs.renameSync(temporary, saved);
|
|
247
|
+
} catch { /* The next snapshot rehashes more files. */ }
|
|
248
|
+
return tree;
|
|
234
249
|
} finally { fs.rmSync(folder, { recursive: true, force: true }); }
|
|
235
250
|
}
|
|
236
251
|
|
|
@@ -345,7 +360,7 @@ export async function pdfWords(pdf: string, page: number) {
|
|
|
345
360
|
// `problems` come from the last finished build. They are placed on the PDF
|
|
346
361
|
// named `problemsKey`; a failed build has no PDF of its own to place them on.
|
|
347
362
|
interface Build {
|
|
348
|
-
tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string;
|
|
363
|
+
tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string; progress?: BuildProgress;
|
|
349
364
|
problems?: StoredProblem[]; problemsKey?: string; log?: string; logFolder?: string;
|
|
350
365
|
}
|
|
351
366
|
|
|
@@ -380,11 +395,14 @@ export class Manuscripts extends EventEmitter {
|
|
|
380
395
|
}
|
|
381
396
|
|
|
382
397
|
async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
|
|
383
|
-
const
|
|
398
|
+
const current = this.builds.get(projectId);
|
|
399
|
+
// Polling an in-flight build needs its status, not another source snapshot.
|
|
400
|
+
// Once it finishes, the next read checks for edits made during compilation.
|
|
401
|
+
const [status, tree] = await Promise.all([gitStatus(root), current?.status === 'building' ? current.tree : workingTree(root)]);
|
|
384
402
|
let build = this.builds.get(projectId);
|
|
385
403
|
if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
|
|
386
404
|
const { status: state, key, pages, builtAt, error } = build;
|
|
387
|
-
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems) } };
|
|
405
|
+
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems), ...(state === 'building' && build.progress ? { progress: build.progress } : {}) } };
|
|
388
406
|
}
|
|
389
407
|
|
|
390
408
|
// Keep the previous pages visible while a newer working tree compiles.
|
|
@@ -397,7 +415,8 @@ export class Manuscripts extends EventEmitter {
|
|
|
397
415
|
try {
|
|
398
416
|
const { Builder, Repo, loadProject, mupdf } = await engine();
|
|
399
417
|
project = loadProject(root);
|
|
400
|
-
const
|
|
418
|
+
const startedAt = Date.now();
|
|
419
|
+
const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
|
|
401
420
|
const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
|
|
402
421
|
const pages: [number, number][] = [];
|
|
403
422
|
for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
|
|
@@ -444,7 +463,7 @@ export class Manuscripts extends EventEmitter {
|
|
|
444
463
|
async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
|
|
445
464
|
const build = this.builds.get(projectId);
|
|
446
465
|
if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
|
|
447
|
-
const { PdfDocument
|
|
466
|
+
const { PdfDocument } = await engine();
|
|
448
467
|
const current = new PdfDocument(build.pdf), target = new AnnotationPages(current);
|
|
449
468
|
const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
|
|
450
469
|
try {
|
package/src/server/paths.ts
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
5
5
|
export const DATA = path.resolve(process.env.PAPILOT_DATA || path.join(ROOT, '.papilot'));
|
|
6
|
-
export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, '
|
|
6
|
+
export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'engine'));
|
|
7
7
|
export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
8
8
|
export const PORT = Number(process.env.PAPILOT_PORT || 7274);
|
|
9
9
|
export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
|
package/src/server/start.ts
CHANGED
|
@@ -21,10 +21,10 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
|
|
|
21
21
|
const manuscripts = new Manuscripts(), changes = new Changes();
|
|
22
22
|
const desktop = process.env.PAPILOT_DESKTOP === '1';
|
|
23
23
|
if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Desktop releases are complete installers; npm updates belong to the web server.
|
|
25
|
+
const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop,
|
|
26
26
|
busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
|
|
27
|
-
const activation = readJson(path.join(DATA, 'updates/active.json'));
|
|
27
|
+
const activation = desktop ? null : readJson(path.join(DATA, 'updates/active.json'));
|
|
28
28
|
const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
|
|
29
29
|
const shared = {
|
|
30
30
|
store, workspace, accounts, manuscripts, changes, updates, updateInstance, csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
|
package/src/server/store.ts
CHANGED
|
@@ -83,14 +83,6 @@ export class Store extends EventEmitter {
|
|
|
83
83
|
});
|
|
84
84
|
this.emit('change');
|
|
85
85
|
}
|
|
86
|
-
// Runs a one-off change the first time this database sees its key.
|
|
87
|
-
migrate(key: string, fn: () => void) {
|
|
88
|
-
this.transaction(() => {
|
|
89
|
-
if (this.db.prepare('SELECT 1 FROM migrations WHERE key=?').get(key)) return;
|
|
90
|
-
fn();
|
|
91
|
-
this.db.prepare('INSERT INTO migrations VALUES(?,?)').run(key, Date.now());
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
86
|
transaction<T>(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (e) { this.db.exec('ROLLBACK'); throw e; } }
|
|
95
87
|
recover() {
|
|
96
88
|
this.transaction(() => {
|
|
@@ -71,7 +71,7 @@ export async function prepareDev(root: string, version: string, log: number, run
|
|
|
71
71
|
// Test-only tools can exclude a Node version that supports the application.
|
|
72
72
|
// Build first, then enforce engine compatibility for the retained runtime.
|
|
73
73
|
await run(['ci', '--include=dev', '--engine-strict=false', ...flags], root, log);
|
|
74
|
-
const engine = path.join(root, '
|
|
74
|
+
const engine = path.join(root, 'engine');
|
|
75
75
|
await run(['ci', '--include=dev', '--engine-strict=false', ...flags], engine, log);
|
|
76
76
|
// Only this installed snapshot gets a dev version; the repository stays unchanged.
|
|
77
77
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
|
|
@@ -103,6 +103,7 @@ async function installDev(data: string, version: string) {
|
|
|
103
103
|
finally { fs.closeSync(log); }
|
|
104
104
|
}
|
|
105
105
|
export async function installRelease(data: string, version: string) {
|
|
106
|
+
if (process.env.PAPILOT_DESKTOP === '1') throw new Error('Download desktop updates from GitHub Releases.');
|
|
106
107
|
if (devRevision(version)) return installDev(data, version);
|
|
107
108
|
if (!stableVersion(version)) throw new Error('Invalid release version.');
|
|
108
109
|
const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
|
|
@@ -113,10 +114,12 @@ export async function installRelease(data: string, version: string) {
|
|
|
113
114
|
await runInstall(['install', '--prefix', stage, '--save-exact', ...flags, `papilot@${version}`], stage, log);
|
|
114
115
|
const pkg = readJson(path.join(root, 'package.json'));
|
|
115
116
|
if (pkg?.name !== 'papilot' || pkg.version !== version) throw new Error('The downloaded package does not match the requested release.');
|
|
116
|
-
|
|
117
|
+
// Releases also ship an empty vendor/latex-review package, where 0.8.2 and
|
|
118
|
+
// earlier look for the engine when they install an update.
|
|
119
|
+
for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'bin/papilot.mjs', 'engine/package-lock.json']) {
|
|
117
120
|
if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
|
|
118
121
|
}
|
|
119
|
-
await runInstall(['ci', '--prefix', path.join(root, '
|
|
122
|
+
await runInstall(['ci', '--prefix', path.join(root, 'engine'), ...flags], stage, log);
|
|
120
123
|
await probeInstallation(root, version, stage, log);
|
|
121
124
|
return root;
|
|
122
125
|
} catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
|
package/src/server/updates.ts
CHANGED
|
@@ -8,34 +8,54 @@ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
|
|
|
8
8
|
|
|
9
9
|
const activePhases = new Set(['installing', 'waiting', 'restarting']);
|
|
10
10
|
export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
11
|
-
async function releaseInfo(url: string, fetcher: typeof fetch) {
|
|
11
|
+
async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000) {
|
|
12
12
|
const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
|
|
13
13
|
if (!response.ok) throw new Error('Could not check for updates.');
|
|
14
14
|
const text = await response.text();
|
|
15
|
-
if (text.length >
|
|
15
|
+
if (text.length > limit) throw new Error('Invalid release information.');
|
|
16
16
|
return JSON.parse(text);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// The package.json of the newest release on a channel.
|
|
19
|
+
export async function latestManifest(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<Record<string, any>> {
|
|
19
20
|
if (channel === 'dev') {
|
|
20
21
|
const commit = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/git/ref/heads/main', fetcher);
|
|
21
22
|
const revision = commit.object?.sha;
|
|
22
23
|
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Invalid development revision.');
|
|
23
24
|
const pkg = await releaseInfo(`https://raw.githubusercontent.com/PapilotAI/papilot/${revision}/package.json`, fetcher);
|
|
24
25
|
if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid development version.');
|
|
25
|
-
return `${pkg.version}-dev.${revision}
|
|
26
|
+
return { ...pkg, version: `${pkg.version}-dev.${revision}` };
|
|
26
27
|
}
|
|
27
28
|
const pkg = await releaseInfo('https://registry.npmjs.org/papilot/latest', fetcher);
|
|
28
29
|
if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
|
|
29
|
-
return pkg
|
|
30
|
+
return pkg;
|
|
31
|
+
}
|
|
32
|
+
export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
|
|
33
|
+
export const DESKTOP_RELEASES = 'https://github.com/PapilotAI/papilot/releases';
|
|
34
|
+
// npm can be published before desktop builds finish. Only advertise releases
|
|
35
|
+
// with an uploaded installer for this OS and CPU, and link to that exact release.
|
|
36
|
+
export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch) {
|
|
37
|
+
const releases = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/releases?per_page=100', fetcher, 4 * 1024 * 1024);
|
|
38
|
+
if (!Array.isArray(releases)) throw new Error('Invalid release information.');
|
|
39
|
+
let latest: { version: string; downloadUrl: string } | null = null;
|
|
40
|
+
for (const release of releases) {
|
|
41
|
+
const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
|
|
42
|
+
if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
|
|
43
|
+
const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
|
|
44
|
+
: platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
|
|
45
|
+
if (!release.assets.some((asset: any) => asset.state === 'uploaded' && asset.size > 0 && names.includes(asset.name))) continue;
|
|
46
|
+
if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}` };
|
|
47
|
+
}
|
|
48
|
+
return latest;
|
|
30
49
|
}
|
|
31
50
|
const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
|
|
32
51
|
const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
|
|
33
52
|
&& (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
|
|
34
53
|
interface Options {
|
|
35
|
-
|
|
54
|
+
desktop?: boolean;
|
|
55
|
+
latestDesktop?: () => Promise<{ version: string; downloadUrl: string } | null>;
|
|
36
56
|
root: string; data: string; version: string; port: number;
|
|
37
57
|
busy: () => boolean; shutdown: () => void;
|
|
38
|
-
latest?: (channel: UpdateChannel) => Promise<string
|
|
58
|
+
latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
|
|
39
59
|
restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
|
|
40
60
|
}
|
|
41
61
|
// Both HTTP listeners share one updater. Downloads do not interrupt editing;
|
|
@@ -43,7 +63,6 @@ interface Options {
|
|
|
43
63
|
export class Updates {
|
|
44
64
|
private state: UpdateStatus;
|
|
45
65
|
private checking: Promise<UpdateStatus> | null = null;
|
|
46
|
-
private operation: Promise<void> | null = null;
|
|
47
66
|
private prepared = '';
|
|
48
67
|
private inFlight = 0;
|
|
49
68
|
private timer?: NodeJS.Timeout;
|
|
@@ -51,28 +70,28 @@ export class Updates {
|
|
|
51
70
|
private file: string;
|
|
52
71
|
private lock: string;
|
|
53
72
|
private channel: UpdateChannel;
|
|
73
|
+
private downloadUrl = DESKTOP_RELEASES;
|
|
54
74
|
constructor(private options: Options) {
|
|
55
|
-
this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
|
|
75
|
+
this.file = path.join(options.data, options.desktop ? 'updates/desktop/status.json' : 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
|
|
56
76
|
const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
|
|
57
|
-
this.channel = options.
|
|
58
|
-
let supported = options.supported ?? true;
|
|
59
|
-
if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
|
|
77
|
+
this.channel = options.desktop ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
|
|
78
|
+
let supported = options.desktop ? false : options.supported ?? true;
|
|
79
|
+
if (!options.desktop && options.supported === undefined) try { npmCli(); } catch { supported = false; }
|
|
60
80
|
this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
|
|
61
|
-
const saved = readJson(this.file);
|
|
81
|
+
const saved = options.desktop ? null : readJson(this.file);
|
|
62
82
|
if (saved && ['failed', 'updated', 'installing', 'waiting', 'restarting'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: options.version, canUpdate: supported };
|
|
63
83
|
if (saved && activePhases.has(saved.phase) && !alive(saved.pid)) this.change({ phase: 'failed', checkedAt: Date.now(), error: 'The update was interrupted. Your current installation is still available; retry the update.' });
|
|
64
84
|
}
|
|
65
85
|
snapshot(): UpdateStatus {
|
|
66
|
-
if (this.options.downloadUrl) return { currentVersion: this.options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: false, channel: 'stable', downloadUrl: this.options.downloadUrl };
|
|
67
86
|
const saved = readJson(this.file);
|
|
68
87
|
if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
|
|
69
|
-
const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state;
|
|
88
|
+
const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state, desktop = this.options.desktop;
|
|
89
|
+
if (desktop) return { currentVersion, latestVersion, phase, checkedAt, error, channel: 'stable', canUpdate: false, downloadUrl: this.downloadUrl };
|
|
70
90
|
return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
|
|
71
91
|
}
|
|
72
92
|
get restarting() { return this.snapshot().phase === 'restarting'; }
|
|
73
93
|
enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
|
|
74
94
|
start() {
|
|
75
|
-
if (this.options.downloadUrl) return;
|
|
76
95
|
if (this.state.phase !== 'failed') void this.check();
|
|
77
96
|
this.timer = setInterval(() => {
|
|
78
97
|
if (this.state.phase === 'waiting') void this.activate();
|
|
@@ -85,7 +104,7 @@ export class Updates {
|
|
|
85
104
|
writeJson(this.file, { ...this.state, pid: process.pid });
|
|
86
105
|
}
|
|
87
106
|
setChannel(channel: unknown) {
|
|
88
|
-
if (this.options.
|
|
107
|
+
if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
|
|
89
108
|
if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
|
|
90
109
|
if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
|
|
91
110
|
if (channel === this.channel) return this.check();
|
|
@@ -96,14 +115,27 @@ export class Updates {
|
|
|
96
115
|
return this.check();
|
|
97
116
|
}
|
|
98
117
|
check(): Promise<UpdateStatus> {
|
|
99
|
-
if (this.options.downloadUrl) return Promise.resolve(this.snapshot());
|
|
100
118
|
if (this.checking) return this.checking;
|
|
101
119
|
if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
|
|
102
120
|
this.change({ phase: 'checking' });
|
|
103
121
|
const channel = this.channel;
|
|
104
|
-
const
|
|
122
|
+
const discovery = this.options.desktop ? (this.options.latestDesktop || latestDesktopRelease)() : (this.options.latest || latestManifest)(channel);
|
|
123
|
+
const checking = discovery.then(found => {
|
|
124
|
+
const manifest = typeof found === 'string' ? { version: found } : found;
|
|
125
|
+
if (!manifest && this.options.desktop) {
|
|
126
|
+
if (!this.closed && this.checking === checking) {
|
|
127
|
+
this.downloadUrl = DESKTOP_RELEASES;
|
|
128
|
+
this.change({ latestVersion: null, checkedAt: Date.now(), error: null, phase: 'idle' });
|
|
129
|
+
}
|
|
130
|
+
return this.snapshot();
|
|
131
|
+
}
|
|
132
|
+
const version = manifest?.version;
|
|
105
133
|
if (!validTarget(version, channel)) throw new Error('Invalid release version.');
|
|
106
|
-
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase))
|
|
134
|
+
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
|
|
135
|
+
const newer = available(version, this.options.version, channel);
|
|
136
|
+
if (this.options.desktop) this.downloadUrl = manifest!.downloadUrl;
|
|
137
|
+
this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
|
|
138
|
+
}
|
|
107
139
|
return this.snapshot();
|
|
108
140
|
}).catch(() => {
|
|
109
141
|
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion && available(this.state.latestVersion, this.options.version, channel) ? 'available' : 'idle', checkedAt: Date.now(), error: 'Could not check for updates. Try again when connected.' });
|
|
@@ -113,7 +145,7 @@ export class Updates {
|
|
|
113
145
|
return this.checking;
|
|
114
146
|
}
|
|
115
147
|
install(version: unknown) {
|
|
116
|
-
if (this.options.
|
|
148
|
+
if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
|
|
117
149
|
if (activePhases.has(this.state.phase)) return this.snapshot();
|
|
118
150
|
if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
|
|
119
151
|
if (!validTarget(version, this.channel) || version !== this.state.latestVersion || !available(version, this.options.version, this.channel)) throw new Error('Check for a newer release before updating.');
|
|
@@ -123,11 +155,11 @@ export class Updates {
|
|
|
123
155
|
if (previous) fs.rmSync(this.lock);
|
|
124
156
|
const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
|
|
125
157
|
this.change({ phase: 'installing', error: null });
|
|
126
|
-
|
|
158
|
+
void (this.options.install || installRelease)(this.options.data, version).then(root => {
|
|
127
159
|
if (this.closed) return;
|
|
128
160
|
this.prepared = root; this.change({ phase: 'waiting' });
|
|
129
161
|
return this.activate();
|
|
130
|
-
}).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); })
|
|
162
|
+
}).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); });
|
|
131
163
|
return this.snapshot();
|
|
132
164
|
}
|
|
133
165
|
// Public for deterministic tests; the periodic tick calls this in production.
|
package/src/server/workspace.ts
CHANGED
|
@@ -451,13 +451,21 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
453
|
if (this.active.get(task.id)?.id !== run.id) return;
|
|
454
|
-
|
|
454
|
+
// Settings can change while the agent loads. Keep this turn's snapshot,
|
|
455
|
+
// and never overwrite a newer choice with the thread's resolved model.
|
|
456
|
+
const current = this.store.task(task.id);
|
|
457
|
+
this.store.updateTask(task.id, { threadId: result.thread.id, ...(current.model === task.model ? { model: result.model || task.model } : {}), ...(task.threadId ? {} : { viewPdf }) });
|
|
458
|
+
task = { ...task, threadId: result.thread.id, model: result.model || task.model };
|
|
455
459
|
this.loaded.add(result.thread.id);
|
|
456
460
|
}
|
|
457
461
|
this.store.updateTask(task.id, { pendingRunId: run.id });
|
|
458
462
|
const input = [{ type: 'text', text: run.prompt }, ...(run.attachments || []).filter(file => file.mime.startsWith('image/')).map(file => ({ type: 'localImage', path: this.attachments.path(file) }))];
|
|
459
463
|
let result: any;
|
|
460
|
-
|
|
464
|
+
const models = this.runtimes[this.agentOf(task)].models;
|
|
465
|
+
const model = task.model ? models.find(model => model.id === task.model) : models.find(model => model.isDefault);
|
|
466
|
+
// Explicit defaults replace a previous turn's reasoning override.
|
|
467
|
+
const effort = task.effort || (this.agentOf(task) === 'codex' ? model?.defaultEffort : undefined);
|
|
468
|
+
try { result = await agent.request('turn/start', { threadId: task.threadId, input, cwd: task.worktree, clientUserMessageId: run.id, ...(task.model ? { model: task.model } : {}), ...(effort ? { effort } : {}), ...(this.agentOf(task) === 'codex' && task.serviceTier ? { serviceTier: task.serviceTier } : {}) }); }
|
|
461
469
|
catch (error) {
|
|
462
470
|
if (this.active.get(task.id)?.id !== run.id) return;
|
|
463
471
|
if (error instanceof RpcResponseError) throw error;
|
|
@@ -1081,6 +1089,20 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
1081
1089
|
this.committing.add(projectId);
|
|
1082
1090
|
try { return await work(); } finally { this.committing.delete(projectId); }
|
|
1083
1091
|
}
|
|
1092
|
+
updateTaskModel(taskId: string, change: { model?: string; effort?: string; serviceTier?: string }) {
|
|
1093
|
+
const task = this.store.task(taskId), agent = this.agentOf(task);
|
|
1094
|
+
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
1095
|
+
const models = this.runtimes[agent].models;
|
|
1096
|
+
const id = change.model ?? task.model;
|
|
1097
|
+
const model = id ? models.find(model => model.id === id) : models.find(model => model.isDefault);
|
|
1098
|
+
if (change.model && change.model !== task.model && !model) throw new Error('Choose an available model for this agent.');
|
|
1099
|
+
const effort = change.effort ?? (change.model !== undefined && model && !model.efforts.includes(task.effort || '') ? '' : task.effort);
|
|
1100
|
+
if (change.effort && model && !model.efforts.includes(change.effort)) throw new Error('This model does not support that reasoning effort.');
|
|
1101
|
+
const serviceTier = change.serviceTier ?? (change.model !== undefined && task.serviceTier !== model?.fastMode?.serviceTier ? 'default' : task.serviceTier);
|
|
1102
|
+
if (change.serviceTier !== undefined && agent !== 'codex') throw new Error('Service tiers are only available for Codex.');
|
|
1103
|
+
if (serviceTier && serviceTier !== 'default' && serviceTier !== model?.fastMode?.serviceTier) throw new Error('Fast mode is not available for this model.');
|
|
1104
|
+
return this.store.updateTask(taskId, { model: id || null, effort: effort || null, ...(serviceTier ? { serviceTier } : {}) });
|
|
1105
|
+
}
|
|
1084
1106
|
renameTask(taskId: string, title: string) {
|
|
1085
1107
|
const task = this.store.task(taskId);
|
|
1086
1108
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
package/src/shared.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const MAX_ATTACHMENT_TOTAL = 100 * 1024 * 1024;
|
|
|
9
9
|
export interface Attachment { id: string; projectId: string; name: string; size: number; mime: string }
|
|
10
10
|
export type UpdateChannel = 'stable' | 'dev';
|
|
11
11
|
export interface UpdateStatus {
|
|
12
|
+
// Present when the update must be downloaded as a new desktop app.
|
|
12
13
|
downloadUrl?: string;
|
|
13
14
|
channel?: UpdateChannel;
|
|
14
15
|
currentVersion: string; latestVersion: string | null;
|
|
@@ -123,7 +124,10 @@ export interface GitWorktree {
|
|
|
123
124
|
}
|
|
124
125
|
export interface GitGraphCommit { hash: string; parents: string[]; subject: string; author: string; date: number; refs: string[] }
|
|
125
126
|
export interface GitOverview { worktrees: GitWorktree[]; commits: GitGraphCommit[]; truncated: boolean }
|
|
126
|
-
|
|
127
|
+
// A running compilation, from latexmk's output: its step, the TeX pass, and
|
|
128
|
+
// the pages typeset in that pass against the last known page count.
|
|
129
|
+
export interface BuildProgress { step: 'tex' | 'bibliography' | 'pdf'; pass: number; pages: number; total: number | null; startedAt: number }
|
|
130
|
+
export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean; problems?: BuildProblemCounts; progress?: BuildProgress }
|
|
127
131
|
// What the last finished build reported. Errors stop the build; warnings are
|
|
128
132
|
// mistakes in the PDF (?? and missing citations); minor problems are
|
|
129
133
|
// typesetting (overfull lines) and package remarks, listed but never counted.
|
|
@@ -153,6 +157,8 @@ export interface PaperImport {
|
|
|
153
157
|
id: string; path: string; name: string; copied: boolean; candidates: string[]; main: string; engine: string;
|
|
154
158
|
phase: 'choose' | 'building' | 'failed' | 'ready' | 'repairing';
|
|
155
159
|
error?: string; summary?: string; versioned?: boolean;
|
|
160
|
+
// While building: when it started, and the compilation's progress.
|
|
161
|
+
startedAt?: number; progress?: BuildProgress;
|
|
156
162
|
}
|
|
157
163
|
// The paper's bibliography as the References panel reads it: the review
|
|
158
164
|
// engine's report of every entry, with online checks and the entries the
|
package/src/skill-metadata.ts
CHANGED
|
@@ -40,6 +40,6 @@ export function setSkillMetadata(content: string, field: 'name' | 'description'
|
|
|
40
40
|
return `${bom}---${newline}${next.join(newline)}${newline}---${closingNewline}${source.slice(header[0].length)}`;
|
|
41
41
|
}
|
|
42
42
|
export function setSkillInstructions(content: string, body: string) {
|
|
43
|
-
const { bom,
|
|
43
|
+
const { bom, header, newline } = parts(content);
|
|
44
44
|
return header ? `${bom}${header[0]}${header[0].endsWith('\n') ? '' : newline}${body}` : `${bom}${body}`;
|
|
45
45
|
}
|