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.
Files changed (112) hide show
  1. package/README.md +4 -2
  2. package/bin/doctor.mjs +1 -1
  3. package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
  4. package/dist/assets/index-0siHHtfz.js +267 -0
  5. package/dist/assets/index-CxdYm81a.css +1 -0
  6. package/dist/assets/{math-Bu3xEyrC.js → math-ibBpTslk.js} +1 -1
  7. package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-CD1kNKYH.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/engine/dist/web/assets/FullPaper-DUgLb01O.js +54 -0
  10. package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
  11. package/engine/dist/web/assets/index-DJywe0wU.js +19 -0
  12. package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  13. package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
  14. package/engine/package-lock.json +3390 -0
  15. package/engine/package.json +50 -0
  16. package/{vendor/latex-review → engine}/src/core/build.ts +198 -99
  17. package/{vendor/latex-review → engine}/src/core/git.ts +133 -28
  18. package/{vendor/latex-review → engine}/src/core/outline.ts +0 -9
  19. package/engine/src/core/progress.ts +67 -0
  20. package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
  21. package/engine/src/locales.ts +47 -0
  22. package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
  23. package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
  24. package/engine/src/render/positions.ts +34 -0
  25. package/engine/src/render/synctex.ts +188 -0
  26. package/engine/src/render/synctex.wasm +0 -0
  27. package/engine/src/render/synctex.wasm.LICENSE +50 -0
  28. package/{vendor/latex-review → engine}/src/server.ts +5 -3
  29. package/{vendor/latex-review → engine}/src/types.ts +13 -0
  30. package/engine/src/web/FullPaper.tsx +318 -0
  31. package/engine/src/web/i18n.ts +36 -0
  32. package/engine/src/web/locales/de.json +253 -0
  33. package/engine/src/web/locales/es.json +253 -0
  34. package/engine/src/web/locales/fr.json +253 -0
  35. package/engine/src/web/locales/index.ts +16 -0
  36. package/engine/src/web/locales/it.json +253 -0
  37. package/engine/src/web/locales/ja.json +253 -0
  38. package/engine/src/web/locales/ko.json +253 -0
  39. package/engine/src/web/locales/pt.json +253 -0
  40. package/engine/src/web/locales/sv.json +253 -0
  41. package/engine/src/web/locales/zh-Hant.json +253 -0
  42. package/engine/src/web/paper.ts +13 -0
  43. package/{vendor/latex-review → engine}/src/web/references.tsx +1 -1
  44. package/{vendor/latex-review → engine}/src/web/review.tsx +99 -2
  45. package/{vendor/latex-review → engine}/src/web/style.css +71 -11
  46. package/engine/src/web/view-preferences.ts +13 -0
  47. package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
  48. package/package.json +18 -9
  49. package/src/conversation.ts +1 -1
  50. package/src/server/acp.ts +9 -5
  51. package/src/server/agents.ts +4 -2
  52. package/src/server/app.ts +23 -0
  53. package/src/server/changes.ts +12 -3
  54. package/src/server/imports.ts +69 -25
  55. package/src/server/main.ts +3 -2
  56. package/src/server/manuscript.ts +31 -12
  57. package/src/server/paths.ts +1 -1
  58. package/src/server/start.ts +3 -3
  59. package/src/server/store.ts +0 -8
  60. package/src/server/update-install.ts +6 -3
  61. package/src/server/updates.ts +55 -23
  62. package/src/server/workspace.ts +24 -2
  63. package/src/shared.ts +7 -1
  64. package/src/skill-metadata.ts +1 -1
  65. package/vendor/latex-review/package-lock.json +4 -3106
  66. package/vendor/latex-review/package.json +3 -46
  67. package/README.zh-CN.md +0 -100
  68. package/dist/assets/index-BlLyIqT9.js +0 -265
  69. package/dist/assets/index-DVZtyZvu.css +0 -1
  70. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
  71. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
  72. package/vendor/latex-review/src/web/i18n.ts +0 -48
  73. /package/{vendor/latex-review → engine}/LICENSE +0 -0
  74. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  75. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  76. /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
  77. /package/{vendor/latex-review → engine}/src/client.ts +0 -0
  78. /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
  79. /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
  80. /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
  81. /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
  82. /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
  83. /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
  84. /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
  85. /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
  86. /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
  87. /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
  88. /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
  89. /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
  90. /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
  91. /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
  92. /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
  93. /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
  94. /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
  95. /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
  96. /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
  97. /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
  98. /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
  99. /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
  100. /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
  101. /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
  102. /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
  103. /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
  104. /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
  105. /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
  106. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  107. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  108. /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
  109. /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
  110. /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
  111. /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
  112. /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  Fragment,
3
+ lazy,
4
+ Suspense,
3
5
  useEffect,
4
6
  useMemo,
5
7
  useRef,
@@ -18,7 +20,11 @@ import type {
18
20
  } from "../types.ts";
19
21
  import { PdfStrip } from "./PdfStrip.tsx";
20
22
  import { ChangeMap } from "./ChangeMap.tsx";
23
+ import { placeable, type PlacedChange } from "./paper.ts";
24
+ // pdf.js loads only when the full paper is shown.
25
+ const FullPaper = lazy(() => import("./FullPaper.tsx").then((module) => ({ default: module.FullPaper })));
21
26
  import { EFFECTS_EVENT, EFFECTS_STORAGE, readExpandedEffects } from "./effect-preferences.ts";
27
+ import { readView, VIEW_EVENT, VIEW_STORAGE, type ReviewView } from "./view-preferences.ts";
22
28
  import { STATUS } from "./overview.tsx";
23
29
  import { language, locale, t, tn, withLanguage } from "./i18n.ts";
24
30
  import {
@@ -167,6 +173,22 @@ export function ReviewApp({
167
173
  scrollUntil = useRef(0),
168
174
  snapshot = useRef({ state, detail, busy, composer, current });
169
175
  const [shortcuts, setShortcuts] = useState(readShortcuts);
176
+ // The whole paper with the changes in place, or the changed sections only.
177
+ // A host may fix the view in the address (Papilot's manuscript chooses its
178
+ // PDF with its own switch); otherwise it follows the stored choice.
179
+ const [pinnedView] = useState<ReviewView | null>(() => {
180
+ const value = new URLSearchParams(location.search).get("view");
181
+ return value === "paper" || value === "changes" ? value : null;
182
+ });
183
+ const [view, setView] = useState<ReviewView>(() => pinnedView ?? readView());
184
+ useEffect(() => {
185
+ if (pinnedView) return;
186
+ const chosen = (event: Event) => setView((event as CustomEvent).detail === "changes" ? "changes" : "paper");
187
+ const stored = (event: StorageEvent) => { if (event.key === VIEW_STORAGE || event.key === null) setView(readView()); };
188
+ window.addEventListener(VIEW_EVENT, chosen);
189
+ window.addEventListener("storage", stored);
190
+ return () => { window.removeEventListener(VIEW_EVENT, chosen); window.removeEventListener("storage", stored); };
191
+ }, [pinnedView]);
170
192
  const [expandedEffects, setExpandedEffects] = useState(readExpandedEffects);
171
193
  // Per-edit disclosure is temporary and scoped to its round. Changing the
172
194
  // global preference resets those overrides without reloading the review.
@@ -195,6 +217,16 @@ export function ReviewApp({
195
217
  [detail],
196
218
  ),
197
219
  stops = useMemo(() => (detail ? buildStops(detail) : []), [detail]),
220
+ // The change blocks in the order and numbering of the changes view.
221
+ placed = useMemo(() => {
222
+ const list: PlacedChange[] = [];
223
+ if (!detail) return list;
224
+ for (const section of detail.sections)
225
+ for (const block of section.blocks)
226
+ if (block.kind === "change") list.push({ block, index: list.length });
227
+ for (const block of detail.loose) list.push({ block, index: list.length });
228
+ return list;
229
+ }, [detail]),
198
230
  // An edit's places show under the first change that includes it.
199
231
  firstStop = useMemo(() => {
200
232
  const found = new Map<string, number>();
@@ -1002,7 +1034,7 @@ export function ReviewApp({
1002
1034
  <p className="reference-problem reference-uncited">
1003
1035
  {uncited
1004
1036
  .map((m) => t("Cites {key}, which no bibliography entry defines.", { key: m.key }))
1005
- .join(language() === "zh-CN" ? "" : " ")}
1037
+ .join(language().startsWith("zh") ? "" : " ")}
1006
1038
  </p>
1007
1039
  )}
1008
1040
  {bib.length > 0 && (
@@ -1094,6 +1126,32 @@ export function ReviewApp({
1094
1126
  </article>
1095
1127
  );
1096
1128
  }
1129
+ const fullPaper = !pinnedView && Boolean(detail?.pdf) && placeable(placed),
1130
+ paperView = fullPaper && view === "paper";
1131
+ // A host shows its own switch while the whole paper can be shown.
1132
+ useEffect(() => {
1133
+ const root = document.documentElement;
1134
+ if (fullPaper) root.dataset.fullPaper = "available";
1135
+ else delete root.dataset.fullPaper;
1136
+ }, [fullPaper]);
1137
+ // Keep the selected change in view when the view changes, from here or the host.
1138
+ const shownView = useRef(paperView);
1139
+ useEffect(() => {
1140
+ if (shownView.current === paperView) return;
1141
+ shownView.current = paperView;
1142
+ requestAnimationFrame(() => {
1143
+ const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${snapshot.current.current}"]`);
1144
+ if (article) reveal(article);
1145
+ });
1146
+ }, [paperView]);
1147
+ function chooseView(next: ReviewView) {
1148
+ setView(next);
1149
+ try {
1150
+ localStorage.setItem(VIEW_STORAGE, next);
1151
+ } catch {
1152
+ /* The choice lasts for this page. */
1153
+ }
1154
+ }
1097
1155
  let index = 0;
1098
1156
  return (
1099
1157
  <>
@@ -1233,6 +1291,24 @@ export function ReviewApp({
1233
1291
  {t("Undo revert")}
1234
1292
  </button>
1235
1293
  )}
1294
+ {fullPaper && (
1295
+ <div className="view-switch" role="group" aria-label={t("Review view")}>
1296
+ <button
1297
+ className={`ghost${view === "paper" ? " active" : ""}`}
1298
+ aria-pressed={view === "paper"}
1299
+ onClick={() => chooseView("paper")}
1300
+ >
1301
+ PDF
1302
+ </button>
1303
+ <button
1304
+ className={`ghost${view === "changes" ? " active" : ""}`}
1305
+ aria-pressed={view === "changes"}
1306
+ onClick={() => chooseView("changes")}
1307
+ >
1308
+ {t("Changes")}
1309
+ </button>
1310
+ </div>
1311
+ )}
1236
1312
  {/* History is a toolbar action; its own row appears only while
1237
1313
  browsing earlier decisions. */}
1238
1314
  {detail && !history && !changes && (
@@ -1462,7 +1538,28 @@ export function ReviewApp({
1462
1538
  />
1463
1539
  )}
1464
1540
  <div className="document">
1465
- {detail ? (
1541
+ {detail && paperView ? (
1542
+ <>
1543
+ <Suspense fallback={<div className="paper-status" role="status"><div className="pulse" /></div>}>
1544
+ <FullPaper
1545
+ url={detail.pdf!}
1546
+ changes={placed}
1547
+ render={(block, at) => change(block, at, true)}
1548
+ scrollRoot={paper}
1549
+ onReady={() => {
1550
+ const article = paper.current?.querySelector<HTMLElement>(
1551
+ `[data-stop="${snapshot.current.current}"]`,
1552
+ );
1553
+ if (article) reveal(article);
1554
+ }}
1555
+ onUnavailable={() => chooseView("changes")}
1556
+ />
1557
+ </Suspense>
1558
+ {!stops.length && !changes && (
1559
+ <p className="empty">{t("No changes to review.")}</p>
1560
+ )}
1561
+ </>
1562
+ ) : detail ? (
1466
1563
  <>
1467
1564
  {detail.sections.map((section) => (
1468
1565
  <section className="part" key={section.node}>
@@ -247,6 +247,33 @@ kbd {
247
247
  border-color: var(--line);
248
248
  }
249
249
 
250
+ /* Full paper or changes only, as one segmented control (a host such as
251
+ Papilot shows its own instead). */
252
+ .view-switch {
253
+ display: inline-grid;
254
+ grid-template-columns: repeat(2, 1fr);
255
+ flex: none;
256
+ gap: 2px;
257
+ padding: 2px;
258
+ border: 1px solid var(--line);
259
+ border-radius: 9px;
260
+ background: var(--head);
261
+ }
262
+ .view-switch .ghost {
263
+ padding: 3px 10px;
264
+ border-radius: 6px;
265
+ font-weight: 500;
266
+ }
267
+ .view-switch .ghost:hover {
268
+ text-decoration: none;
269
+ border-color: transparent;
270
+ }
271
+ .view-switch .ghost.active {
272
+ color: var(--ink);
273
+ background: var(--paper);
274
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 0 0 1px var(--line);
275
+ }
276
+
250
277
  /* Document */
251
278
  #paper {
252
279
  overflow: auto;
@@ -399,6 +426,7 @@ kbd {
399
426
  mix-blend-mode: multiply;
400
427
  }
401
428
  .side::after {
429
+ content: attr(aria-label);
402
430
  position: absolute;
403
431
  top: 4px;
404
432
  right: 8px;
@@ -409,20 +437,9 @@ kbd {
409
437
  opacity: 0.9;
410
438
  pointer-events: none;
411
439
  }
412
- .side.before::after {
413
- content: "before";
414
- }
415
440
  .side.after::after {
416
- content: "after";
417
441
  color: var(--after-edge);
418
442
  }
419
- /* :where keeps the original weight, so later rules still hide the labels. */
420
- :where(:lang(zh-CN)) .side.before::after {
421
- content: "修改前";
422
- }
423
- :where(:lang(zh-CN)) .side.after::after {
424
- content: "修改后";
425
- }
426
443
  .side.after.identical::after,
427
444
  .change[data-resolved] .side::after {
428
445
  content: none;
@@ -908,6 +925,49 @@ kbd {
908
925
  text-align: center;
909
926
  padding: 90px 16px;
910
927
  }
928
+ /* The whole paper: the new PDF's pages, opened where each change is shown
929
+ as in the changes view. A host's narrow-screen crop of the strips
930
+ (--pdf-crop-scale, --pdf-crop-shift) applies to the pages too. */
931
+ .paper-page {
932
+ background: var(--pdf-page, var(--paper));
933
+ box-shadow: var(--shadow);
934
+ border-radius: 3px;
935
+ margin-bottom: 14px;
936
+ }
937
+ .paper-context {
938
+ line-height: 0;
939
+ }
940
+ .paper-slice {
941
+ position: relative;
942
+ width: 100%;
943
+ }
944
+ .paper-slice canvas {
945
+ position: absolute;
946
+ inset: 0;
947
+ width: 100%;
948
+ height: 100%;
949
+ filter: var(--pdf-filter, none);
950
+ }
951
+ @media (max-width: 680px) {
952
+ .paper-slice {
953
+ width: calc(100% * var(--pdf-crop-scale, 1));
954
+ margin-left: calc(-100% * var(--pdf-crop-scale, 1) * var(--pdf-crop-shift, 0));
955
+ }
956
+ .paper-context {
957
+ overflow-x: clip;
958
+ }
959
+ }
960
+ .paper-status {
961
+ text-align: center;
962
+ padding: 90px 16px;
963
+ color: var(--muted);
964
+ }
965
+ .paper-status p {
966
+ margin: 6px 0 14px;
967
+ }
968
+ .paper-clock {
969
+ font-variant-numeric: tabular-nums;
970
+ }
911
971
  .waiting p {
912
972
  margin: 6px 0;
913
973
  }
@@ -0,0 +1,13 @@
1
+ // Whole paper or changed sections only. A host such as Papilot shows its own
2
+ // switch: it stores the choice and tells this frame; storage events also
3
+ // update other tabs.
4
+ export const VIEW_STORAGE = "latex-review:view";
5
+ export const VIEW_EVENT = "latex-review:view-changed";
6
+ export type ReviewView = "paper" | "changes";
7
+ export function readView(): ReviewView {
8
+ try {
9
+ return localStorage.getItem(VIEW_STORAGE) === "changes" ? "changes" : "paper";
10
+ } catch {
11
+ return "paper";
12
+ }
13
+ }
@@ -66,6 +66,9 @@ export const zhCN: Record<string, string> = {
66
66
  "Review history": "审阅历史",
67
67
  "Keyboard shortcuts": "键盘快捷键",
68
68
  "Review view": "审阅视图",
69
+ "Changes": "改动",
70
+ "Preparing the full PDF…": "正在准备完整 PDF…",
71
+ "The full PDF could not be loaded.": "无法加载完整 PDF。",
69
72
  "Undo {action} · {changes}": "撤销{action} · {changes}",
70
73
  "Change {list}": "修改 {list}",
71
74
  "Changes {list}": "修改 {list}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -48,34 +48,40 @@
48
48
  "scripts/demo.ts",
49
49
  "scripts/service.ts",
50
50
  "examples/papilot-paper",
51
- "vendor/latex-review/src",
52
- "vendor/latex-review/dist",
51
+ "engine/src",
52
+ "engine/dist",
53
+ "engine/package.json",
54
+ "engine/package-lock.json",
55
+ "engine/LICENSE",
53
56
  "vendor/latex-review/package.json",
54
- "vendor/latex-review/package-lock.json",
55
- "vendor/latex-review/LICENSE"
57
+ "vendor/latex-review/package-lock.json"
56
58
  ],
57
59
  "engines": {
58
60
  "node": ">=22.22.0"
59
61
  },
62
+ "desktopShell": 2,
60
63
  "scripts": {
61
64
  "dev": "tsx watch src/server/main.ts",
62
65
  "start": "tsx src/server/main.ts",
63
66
  "build": "tsc --noEmit && vite build",
64
67
  "typecheck": "tsc --noEmit && tsc -p desktop/tsconfig.json --noEmit",
65
68
  "test": "tsx --test tests/*.test.ts",
66
- "setup:review": "npm --prefix vendor/latex-review ci && npm --prefix vendor/latex-review run build",
69
+ "setup:review": "npm --prefix engine ci && npm --prefix engine run build",
67
70
  "demo": "tsx scripts/demo.ts",
68
71
  "doctor": "node bin/papilot.mjs doctor",
69
72
  "service:install": "tsx scripts/service.ts install",
70
73
  "service:stop": "tsx scripts/service.ts stop",
71
- "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
74
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
72
75
  "site": "node scripts/build-site.mjs",
73
76
  "test:site": "npm run site && node scripts/check-site.mjs",
74
77
  "prepack": "npm run build && npm run setup:review",
75
78
  "desktop:compile": "tsc -p desktop/tsconfig.json",
76
79
  "desktop": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs dev",
77
80
  "desktop:build": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs package",
78
- "test:desktop": "node scripts/check-desktop.mjs"
81
+ "test:desktop": "node scripts/check-desktop.mjs",
82
+ "i18n": "tsx scripts/i18n.ts",
83
+ "i18n:check-app": "tsx scripts/i18n/app-locales.ts",
84
+ "test:locales": "npm run build && npm --prefix engine run build && node --import tsx scripts/check-locales.mjs"
79
85
  },
80
86
  "dependencies": {
81
87
  "@fastify/compress": "^9.2.2",
@@ -109,6 +115,9 @@
109
115
  "remark-gfm": "^4.0.1",
110
116
  "remark-math": "^6.0.0",
111
117
  "typescript": "^7.0.2",
112
- "vite": "^8.3.5"
118
+ "vite": "^8.3.5",
119
+ "parse5": "^8.0.1",
120
+ "@babel/parser": "^8.0.10",
121
+ "@babel/types": "^8.0.10"
113
122
  }
114
123
  }
@@ -33,7 +33,7 @@ export function skillPrompt(skill: { name: string; body: string; folder?: string
33
33
  }
34
34
 
35
35
  interface FeedbackItem { kind?: string; round?: number | null; text?: string; changes?: (string | { file?: string; lines?: [number, number] })[] }
36
- // The review engine's feedback (see vendor/latex-review FeedbackItem) as the chat shows it.
36
+ // The review engine's feedback (see engine FeedbackItem) as the chat shows it.
37
37
  export function summarizeFeedback(feedback: { feedback?: FeedbackItem[]; build_error?: unknown }): FeedbackSummary | undefined {
38
38
  const items = feedback.feedback || [];
39
39
  if (!items.length && !feedback.build_error) return undefined;
package/src/server/acp.ts CHANGED
@@ -218,7 +218,8 @@ export class AcpAgent extends Adapter {
218
218
  const previous = this.catalog.find(entry => entry.model === option.value);
219
219
  const entry = { model: option.value, displayName: option.name, isDefault: option.value === this.defaultModel,
220
220
  supportedReasoningEfforts: option.value === current ? levels.map(choice => ({ reasoningEffort: choice.value })) : previous?.supportedReasoningEfforts || [],
221
- defaultReasoningEffort: option.value === current ? level : previous?.defaultReasoningEffort || null };
221
+ // A turn's explicit effort must not become the catalog's default.
222
+ defaultReasoningEffort: previous?.supportedReasoningEfforts.length ? previous.defaultReasoningEffort : option.value === current ? level : null };
222
223
  if (previous) Object.assign(previous, entry); else this.catalog.push(entry);
223
224
  }
224
225
  }
@@ -239,10 +240,13 @@ export class AcpAgent extends Adapter {
239
240
  };
240
241
  try {
241
242
  const session = await this.session(turn.threadId, thread.cwd, thread.model);
242
- if (effort) {
243
- const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
244
- if (!option || !choices(option).some(choice => choice.value === effort)) throw new Error(`${this.name} does not support reasoning effort ${effort} for this model.`);
245
- await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: effort });
243
+ const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
244
+ const defaultEffort = choices(option).some(choice => choice.value === 'default') ? 'default' : this.catalog.find(model => model.model === thread.model)?.defaultReasoningEffort;
245
+ const chosenEffort = effort || defaultEffort;
246
+ if (chosenEffort) {
247
+ if (!option || !choices(option).some(choice => choice.value === chosenEffort)) throw new Error(`${this.name} does not support reasoning effort ${chosenEffort} for this model.`);
248
+ const result = await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: chosenEffort });
249
+ if (result?.configOptions) session.configOptions = result.configOptions;
246
250
  }
247
251
  const prompt: any[] = [{ type: 'text', text: thread.started ? text : `${thread.instructions}\n\n---\n\n${text}` }];
248
252
  if (images.length && !this.capabilities.promptCapabilities?.image) throw new Error(`This ${this.name} version does not support image input. Update it, or send the images to another agent.`);
@@ -8,7 +8,7 @@ import { createRequire } from 'node:module';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { pathToFileURL } from 'node:url';
11
- import type { AccountInfo, ModelInfo, Options, PermissionResult, Query, SDKMessage, SDKRateLimitInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
11
+ import type { AccountInfo, ModelInfo, Options, PermissionResult, SDKMessage, SDKRateLimitInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
12
12
  import { AGENT_NAMES } from '../shared.ts';
13
13
  import { DATA, ROOT } from './paths.ts';
14
14
  import { imageType } from './attachments.ts';
@@ -79,13 +79,15 @@ export abstract class Adapter extends EventEmitter implements AgentRuntime {
79
79
  if (method === 'turn/start') {
80
80
  const thread = this.threads.get(String(params.threadId));
81
81
  if (!thread) throw new Error('Start or resume the thread before starting a turn.');
82
+ if (params.model !== undefined) thread.model = params.model || null;
82
83
  const text = (params.input || []).filter((part: { text?: unknown }) => typeof part.text === 'string').map((part: { text: string }) => part.text).join('\n');
83
84
  const images = (params.input || []).filter((part: { type?: string; path?: string }) => part.type === 'localImage' && typeof part.path === 'string').map((part: { path: string }) => part.path);
84
85
  const turn: Turn = { id: randomUUID(), threadId: String(params.threadId), clientId: params.clientUserMessageId, status: 'inProgress', stop: () => {} };
85
86
  turn.stop = () => this.finish(turn, 'interrupted');
86
87
  this.turns.set(turn.id, turn);
87
88
  // Papilot records the turn ID before the first event arrives.
88
- setImmediate(() => { if (turn.status === 'inProgress') this.run(turn, { ...thread, cwd: params.cwd || thread.cwd }, text, params.effort || null, images); });
89
+ const settings = { ...thread, cwd: params.cwd || thread.cwd };
90
+ setImmediate(() => { if (turn.status === 'inProgress') this.run(turn, settings, text, params.effort || null, images); });
89
91
  return { turn: { id: turn.id } };
90
92
  }
91
93
  if (method === 'turn/interrupt') { this.turns.get(String(params.turnId))?.stop(); return {}; }
package/src/server/app.ts CHANGED
@@ -263,6 +263,13 @@ export async function buildApp(options: AppOptions) {
263
263
  const task = store.task(request.params.id);
264
264
  return workspace.commitMessage(task.projectId, task.review?.files || [], { taskId: task.id, agent: task.agent, fresh: (request.body as any)?.fresh === true });
265
265
  });
266
+ app.post<{ Params: { id: string } }>('/api/tasks/:id/model', task, request => {
267
+ const b = (request.body || {}) as Record<string, unknown>;
268
+ if (Object.keys(b).some(key => !['model', 'effort', 'serviceTier'].includes(key))) throw new Error('Only the model and its settings can be changed.');
269
+ if (b.model !== undefined && (typeof b.model !== 'string' || b.model.length > 200)) throw new Error('Invalid model.');
270
+ if (b.effort !== undefined && b.effort !== '' && !effort(b.effort)) throw new Error('Invalid reasoning effort.');
271
+ return workspace.updateTaskModel(request.params.id, { ...(b.model !== undefined ? { model: b.model as string } : {}), ...(b.effort !== undefined ? { effort: b.effort as string } : {}), ...(b.serviceTier !== undefined ? { serviceTier: string(b.serviceTier, 'Service tier', 64) } : {}) });
272
+ });
266
273
  app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', task, request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
267
274
  app.post<{ Params: { id: string } }>('/api/tasks/:id/title', task, request => workspace.requestTaskTitle(request.params.id));
268
275
  app.post<{ Params: { id: string } }>('/api/tasks/:id/archive', task, request => workspace.archiveTask(request.params.id, (request.body as any)?.archived, (request.body as any)?.finished === true));
@@ -466,6 +473,10 @@ export async function buildApp(options: AppOptions) {
466
473
  if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
467
474
  return changes.confirmReference(project.id, project.path, Number(request.params.number), b.id, b.confirmed);
468
475
  });
476
+ app.get<{ Params: { projectId: string; number: string } }>('/review/:projectId/changes/pdf/:number', changesAccess, (request, reply) => {
477
+ const pdf = changes.pdf(store.project(request.params.projectId).id, Number(request.params.number));
478
+ return reply.type('application/pdf').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
479
+ });
469
480
  app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
470
481
  const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
471
482
  return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
@@ -484,6 +495,14 @@ export async function buildApp(options: AppOptions) {
484
495
  return serialGit(project.id, () => workspace.exclusiveCommit(project.id, () => changes.undo(project.id, project.path, csrfFor(request.viewer!), id)));
485
496
  });
486
497
 
498
+ // The complete PDF of a round's new version, for the review's full paper.
499
+ // A round may compile only the changed chapters; this compiles them all once.
500
+ app.get<{ Params: { projectId: string; name: string; round: string } }>('/review/:projectId/pdf/:name/:round', { config: { access: { project: 'projectId' } } }, async (request, reply) => {
501
+ const project = store.project(request.params.projectId), name = request.params.name, round = Number(request.params.round);
502
+ if (!/^[a-z0-9-]+$/.test(name) || !Number.isSafeInteger(round) || round < 1) return reply.code(404).send({ error: 'Review resource not found.' });
503
+ const pdf = await fullPaperPdf(project.path, name, round, 'after');
504
+ return reply.type('application/pdf').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
505
+ });
487
506
  app.route<{ Params: { projectId: string; '*': string } }>({
488
507
  // The review page posts with the review engine's token rather than Papilot's.
489
508
  method: ['GET', 'POST'], url: '/review/:projectId/*', config: { access: { project: 'projectId' }, csrf: false }, handler: async (request, reply) => {
@@ -521,6 +540,10 @@ export async function buildApp(options: AppOptions) {
521
540
  await workspace.refresh(task.id);
522
541
  }
523
542
  }
543
+ // A round's new version as one complete PDF, which Papilot compiles,
544
+ // lets the review show the changes in the whole paper.
545
+ const round = request.method === 'GET' && response.ok ? /^\/api\/tasks\/([a-z0-9-]+)\/rounds\/(\d+)$/.exec(route) : null;
546
+ if (round) (value as { pdf?: string }).pdf = `${prefix}/pdf/${round[1]}/${round[2]}`;
524
547
  return rewriteReviewJson(value, prefix);
525
548
  }
526
549
  if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');
@@ -17,7 +17,7 @@ type Op = ['equal' | 'replace' | 'insert' | 'delete', number, number, number, nu
17
17
  export interface Unit { id: string; file: string; status: 'added' | 'deleted' | 'modified'; kind: string; whole_file: boolean; ops: Op[]; old_start: number; old_end: number; new_start: number; new_end: number; old_text: string; new_text: string; truncated: boolean }
18
18
  type Decision = 'pending' | 'accepted' | 'rejected' | 'commented';
19
19
  export type ChangeAction = 'stage' | 'unstage' | 'revert';
20
- interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision> }
20
+ interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision>; pdf?: string }
21
21
  interface Job { key: string; phase: 'build' | 'render'; status: 'running' | 'failed'; created: number; error?: string; details?: string[]; log?: string }
22
22
  interface Snapshot { before: string | null; after: string }
23
23
  interface Entry { mode: string; sha: string }
@@ -139,7 +139,7 @@ export class Changes {
139
139
  const { Builder, BuildError, Repo, loadProject, withUnits, render } = await engine();
140
140
  const project = loadProject(root), repo = new Repo(root), number = comparison.next++;
141
141
  const units = await this.units(projectId, root, snapshot), folder = path.join(this.dir, projectId, String(number));
142
- let warnings: string[] = [];
142
+ let warnings: string[] = [], pdf: string | undefined;
143
143
  fs.rmSync(folder, { recursive: true, force: true }); fs.mkdirSync(folder, { recursive: true });
144
144
  if (units.length) {
145
145
  const builder = new Builder(project, repo);
@@ -154,8 +154,9 @@ export class Changes {
154
154
  await render(folder, { main: project.main, source_dir: project.source_dir, figures: project.figures, old, new: next, units },
155
155
  (subset: Unit[]) => builder.build(withUnits(repo, snapshot.before!, snapshot.after, subset), ['full']));
156
156
  warnings = next.warnings.filter((warning: string) => !old.warnings.includes(warning));
157
+ pdf = next.pdf;
157
158
  } else fs.writeFileSync(path.join(folder, 'round.json'), JSON.stringify({ units: {}, sections: [], loose: [] }));
158
- comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {} });
159
+ comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {}, pdf });
159
160
  // The viewer may still show the round before; older ones go.
160
161
  for (const old of comparison.rounds.splice(0, Math.max(0, comparison.rounds.length - 3))) fs.rmSync(old.folder, { recursive: true, force: true });
161
162
  if (comparison.job === job) comparison.job = null;
@@ -203,6 +204,8 @@ export class Changes {
203
204
  task: 'changes', round: round.number, before: round.before, after: round.after,
204
205
  units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
205
206
  warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
207
+ // The working tree compiles in full, so its PDF shows the whole paper.
208
+ ...(round.pdf ? { pdf: `/review/${projectId}/changes/pdf/${round.number}` } : {}),
206
209
  ...(render.references ? { references: render.references } : {}),
207
210
  };
208
211
  }
@@ -238,6 +241,12 @@ export class Changes {
238
241
  return { places: await more(round.folder, unit) };
239
242
  }
240
243
 
244
+ pdf(projectId: string, number: number) {
245
+ const file = this.comparison(projectId).rounds.find(item => item.number === number)?.pdf;
246
+ if (!file || !fs.existsSync(file)) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
247
+ return file;
248
+ }
249
+
241
250
  file(projectId: string, number: number, name: string) {
242
251
  const round = this.comparison(projectId).rounds.find(item => item.number === number);
243
252
  const file = round && /^[\w.-]+\.(svg|png)$/.test(name) ? path.join(round.folder, name) : '';