p-spore 0.1.4 → 0.1.43
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/LICENSE +21 -0
- package/README.md +242 -67
- package/dist/agents/agent-manager.d.ts +3 -3
- package/dist/agents/agent-manager.d.ts.map +1 -1
- package/dist/agents/agent-manager.js +3 -2
- package/dist/agents/agent-manager.js.map +1 -1
- package/dist/application/agent-application.d.ts +29 -5
- package/dist/application/agent-application.d.ts.map +1 -1
- package/dist/application/agent-application.js +444 -14
- package/dist/application/agent-application.js.map +1 -1
- package/dist/articles/diff.d.ts +70 -0
- package/dist/articles/diff.d.ts.map +1 -0
- package/dist/articles/diff.js +330 -0
- package/dist/articles/diff.js.map +1 -0
- package/dist/articles/docx.d.ts +10 -0
- package/dist/articles/docx.d.ts.map +1 -0
- package/dist/articles/docx.js +219 -0
- package/dist/articles/docx.js.map +1 -0
- package/dist/articles/draft.d.ts +18 -0
- package/dist/articles/draft.d.ts.map +1 -0
- package/dist/articles/draft.js +88 -0
- package/dist/articles/draft.js.map +1 -0
- package/dist/articles/edits.d.ts +24 -0
- package/dist/articles/edits.d.ts.map +1 -0
- package/dist/articles/edits.js +54 -0
- package/dist/articles/edits.js.map +1 -0
- package/dist/articles/html.d.ts +10 -0
- package/dist/articles/html.d.ts.map +1 -0
- package/dist/articles/html.js +122 -0
- package/dist/articles/html.js.map +1 -0
- package/dist/articles/plain.d.ts +8 -0
- package/dist/articles/plain.d.ts.map +1 -0
- package/dist/articles/plain.js +69 -0
- package/dist/articles/plain.js.map +1 -0
- package/dist/articles/preview.d.ts +43 -0
- package/dist/articles/preview.d.ts.map +1 -0
- package/dist/articles/preview.js +90 -0
- package/dist/articles/preview.js.map +1 -0
- package/dist/articles/publish.d.ts +10 -0
- package/dist/articles/publish.d.ts.map +1 -0
- package/dist/articles/publish.js +54 -0
- package/dist/articles/publish.js.map +1 -0
- package/dist/articles/receipt.d.ts +16 -0
- package/dist/articles/receipt.d.ts.map +1 -0
- package/dist/articles/receipt.js +27 -0
- package/dist/articles/receipt.js.map +1 -0
- package/dist/articles/store.d.ts +78 -0
- package/dist/articles/store.d.ts.map +1 -0
- package/dist/articles/store.js +308 -0
- package/dist/articles/store.js.map +1 -0
- package/dist/articles/tool.d.ts +14 -0
- package/dist/articles/tool.d.ts.map +1 -0
- package/dist/articles/tool.js +175 -0
- package/dist/articles/tool.js.map +1 -0
- package/dist/articles/writer-context.d.ts +5 -0
- package/dist/articles/writer-context.d.ts.map +1 -0
- package/dist/articles/writer-context.js +56 -0
- package/dist/articles/writer-context.js.map +1 -0
- package/dist/articles/writer.d.ts +18 -0
- package/dist/articles/writer.d.ts.map +1 -0
- package/dist/articles/writer.js +79 -0
- package/dist/articles/writer.js.map +1 -0
- package/dist/articles/zip-write.d.ts +11 -0
- package/dist/articles/zip-write.d.ts.map +1 -0
- package/dist/articles/zip-write.js +63 -0
- package/dist/articles/zip-write.js.map +1 -0
- package/dist/bundled-skills/README.md +17 -0
- package/dist/bundled-skills/aegis-search/SKILL.md +59 -0
- package/dist/bundled-skills/aegis-search/bin/.gitkeep +0 -0
- package/dist/bundled-skills/article-canvas/SKILL.md +28 -0
- package/dist/bundled-skills/binaries.json +23 -0
- package/dist/bundled-skills/doc-extract/SKILL.md +56 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +407 -54
- package/dist/cli/main.js.map +1 -1
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +150 -6
- package/dist/config/config.js.map +1 -1
- package/dist/daemon/daemon.d.ts.map +1 -1
- package/dist/daemon/daemon.js +80 -11
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/desktop/application.d.ts +4 -0
- package/dist/desktop/application.d.ts.map +1 -0
- package/dist/desktop/application.js +1688 -0
- package/dist/desktop/application.js.map +1 -0
- package/dist/desktop/article-export.d.ts +12 -0
- package/dist/desktop/article-export.d.ts.map +1 -0
- package/dist/desktop/article-export.js +87 -0
- package/dist/desktop/article-export.js.map +1 -0
- package/dist/desktop/assets/pspore.png +0 -0
- package/dist/desktop/assets/pspore.svg +24 -0
- package/dist/desktop/conversation-smoke.d.ts +5 -0
- package/dist/desktop/conversation-smoke.d.ts.map +1 -0
- package/dist/desktop/conversation-smoke.js +107 -0
- package/dist/desktop/conversation-smoke.js.map +1 -0
- package/dist/desktop/daemon-bridge.d.ts.map +1 -1
- package/dist/desktop/daemon-bridge.js +62 -17
- package/dist/desktop/daemon-bridge.js.map +1 -1
- package/dist/desktop/ipc-contract.d.ts +124 -0
- package/dist/desktop/ipc-contract.d.ts.map +1 -1
- package/dist/desktop/ipc-contract.js +191 -12
- package/dist/desktop/ipc-contract.js.map +1 -1
- package/dist/desktop/local-path.d.ts +15 -0
- package/dist/desktop/local-path.d.ts.map +1 -0
- package/dist/desktop/local-path.js +59 -0
- package/dist/desktop/local-path.js.map +1 -0
- package/dist/desktop/main.d.ts +10 -1
- package/dist/desktop/main.d.ts.map +1 -1
- package/dist/desktop/main.js +122 -530
- package/dist/desktop/main.js.map +1 -1
- package/dist/desktop/native-startup.d.ts +4 -0
- package/dist/desktop/native-startup.d.ts.map +1 -0
- package/dist/desktop/native-startup.js +57 -0
- package/dist/desktop/native-startup.js.map +1 -0
- package/dist/desktop/preload.cjs +28 -1
- package/dist/desktop/renderer/article-panel.d.ts +45 -0
- package/dist/desktop/renderer/article-panel.d.ts.map +1 -0
- package/dist/desktop/renderer/article-panel.js +1126 -0
- package/dist/desktop/renderer/article-panel.js.map +1 -0
- package/dist/desktop/renderer/article-split.d.ts +32 -0
- package/dist/desktop/renderer/article-split.d.ts.map +1 -0
- package/dist/desktop/renderer/article-split.js +48 -0
- package/dist/desktop/renderer/article-split.js.map +1 -0
- package/dist/desktop/renderer/composer-file-references.d.ts +23 -0
- package/dist/desktop/renderer/composer-file-references.d.ts.map +1 -0
- package/dist/desktop/renderer/composer-file-references.js +51 -0
- package/dist/desktop/renderer/composer-file-references.js.map +1 -0
- package/dist/desktop/renderer/conversation.d.ts +23 -0
- package/dist/desktop/renderer/conversation.d.ts.map +1 -0
- package/dist/desktop/renderer/conversation.js +66 -0
- package/dist/desktop/renderer/conversation.js.map +1 -0
- package/dist/desktop/renderer/dom-reconcile.d.ts +15 -0
- package/dist/desktop/renderer/dom-reconcile.d.ts.map +1 -0
- package/dist/desktop/renderer/dom-reconcile.js +33 -0
- package/dist/desktop/renderer/dom-reconcile.js.map +1 -0
- package/dist/desktop/renderer/index.html +211 -34
- package/dist/desktop/renderer/indexing-view.d.ts +22 -0
- package/dist/desktop/renderer/indexing-view.d.ts.map +1 -0
- package/dist/desktop/renderer/indexing-view.js +115 -0
- package/dist/desktop/renderer/indexing-view.js.map +1 -0
- package/dist/desktop/renderer/markdown-view.d.ts +23 -0
- package/dist/desktop/renderer/markdown-view.d.ts.map +1 -0
- package/dist/desktop/renderer/markdown-view.js +436 -0
- package/dist/desktop/renderer/markdown-view.js.map +1 -0
- package/dist/desktop/renderer/motion.d.ts +67 -0
- package/dist/desktop/renderer/motion.d.ts.map +1 -0
- package/dist/desktop/renderer/motion.js +296 -0
- package/dist/desktop/renderer/motion.js.map +1 -0
- package/dist/desktop/renderer/renderer.js +1799 -152
- package/dist/desktop/renderer/renderer.js.map +1 -1
- package/dist/desktop/renderer/session-find.d.ts +18 -0
- package/dist/desktop/renderer/session-find.d.ts.map +1 -0
- package/dist/desktop/renderer/session-find.js +29 -0
- package/dist/desktop/renderer/session-find.js.map +1 -0
- package/dist/desktop/renderer/session-search.d.ts +30 -0
- package/dist/desktop/renderer/session-search.d.ts.map +1 -0
- package/dist/desktop/renderer/session-search.js +296 -0
- package/dist/desktop/renderer/session-search.js.map +1 -0
- package/dist/desktop/renderer/store.d.ts +33 -0
- package/dist/desktop/renderer/store.d.ts.map +1 -1
- package/dist/desktop/renderer/store.js +92 -19
- package/dist/desktop/renderer/store.js.map +1 -1
- package/dist/desktop/renderer/styles.css +928 -268
- package/dist/desktop/renderer/vendor/gsap.min.js +11 -0
- package/dist/desktop/renderer-diagnostics.d.ts +5 -0
- package/dist/desktop/renderer-diagnostics.d.ts.map +1 -0
- package/dist/desktop/renderer-diagnostics.js +47 -0
- package/dist/desktop/renderer-diagnostics.js.map +1 -0
- package/dist/desktop/security.d.ts +26 -1
- package/dist/desktop/security.d.ts.map +1 -1
- package/dist/desktop/security.js +26 -6
- package/dist/desktop/security.js.map +1 -1
- package/dist/desktop/smoke-fixture.d.ts.map +1 -1
- package/dist/desktop/smoke-fixture.js +77 -3
- package/dist/desktop/smoke-fixture.js.map +1 -1
- package/dist/desktop/splash/index.html +25 -0
- package/dist/desktop/splash/splash.d.ts +2 -0
- package/dist/desktop/splash/splash.d.ts.map +1 -0
- package/dist/desktop/splash/splash.js +8 -0
- package/dist/desktop/splash/splash.js.map +1 -0
- package/dist/desktop/splash/styles.css +24 -0
- package/dist/desktop/system-open.d.ts +16 -0
- package/dist/desktop/system-open.d.ts.map +1 -0
- package/dist/desktop/system-open.js +90 -0
- package/dist/desktop/system-open.js.map +1 -0
- package/dist/desktop/validation.d.ts.map +1 -1
- package/dist/desktop/validation.js +99 -21
- package/dist/desktop/validation.js.map +1 -1
- package/dist/desktop/window-security.d.ts +4 -0
- package/dist/desktop/window-security.d.ts.map +1 -0
- package/dist/desktop/window-security.js +26 -0
- package/dist/desktop/window-security.js.map +1 -0
- package/dist/documents/errors.d.ts +12 -0
- package/dist/documents/errors.d.ts.map +1 -0
- package/dist/documents/errors.js +12 -0
- package/dist/documents/errors.js.map +1 -0
- package/dist/documents/extract.d.ts +37 -0
- package/dist/documents/extract.d.ts.map +1 -0
- package/dist/documents/extract.js +90 -0
- package/dist/documents/extract.js.map +1 -0
- package/dist/documents/legacy-office.d.ts +23 -0
- package/dist/documents/legacy-office.d.ts.map +1 -0
- package/dist/documents/legacy-office.js +439 -0
- package/dist/documents/legacy-office.js.map +1 -0
- package/dist/documents/ole.d.ts +20 -0
- package/dist/documents/ole.d.ts.map +1 -0
- package/dist/documents/ole.js +189 -0
- package/dist/documents/ole.js.map +1 -0
- package/dist/documents/ooxml.d.ts +23 -0
- package/dist/documents/ooxml.d.ts.map +1 -0
- package/dist/documents/ooxml.js +197 -0
- package/dist/documents/ooxml.js.map +1 -0
- package/dist/documents/pdf.d.ts +20 -0
- package/dist/documents/pdf.d.ts.map +1 -0
- package/dist/documents/pdf.js +95 -0
- package/dist/documents/pdf.js.map +1 -0
- package/dist/documents/plain-text.d.ts +22 -0
- package/dist/documents/plain-text.d.ts.map +1 -0
- package/dist/documents/plain-text.js +57 -0
- package/dist/documents/plain-text.js.map +1 -0
- package/dist/documents/zip.d.ts +15 -0
- package/dist/documents/zip.d.ts.map +1 -0
- package/dist/documents/zip.js +137 -0
- package/dist/documents/zip.js.map +1 -0
- package/dist/domain/indexing.d.ts +60 -0
- package/dist/domain/indexing.d.ts.map +1 -0
- package/dist/domain/indexing.js +13 -0
- package/dist/domain/indexing.js.map +1 -0
- package/dist/domain/runtime.d.ts +133 -5
- package/dist/domain/runtime.d.ts.map +1 -1
- package/dist/gateway/local-gateway.d.ts +8 -0
- package/dist/gateway/local-gateway.d.ts.map +1 -1
- package/dist/gateway/local-gateway.js +105 -21
- package/dist/gateway/local-gateway.js.map +1 -1
- package/dist/indexing/index-service.d.ts +56 -0
- package/dist/indexing/index-service.d.ts.map +1 -0
- package/dist/indexing/index-service.js +262 -0
- package/dist/indexing/index-service.js.map +1 -0
- package/dist/kernel/loop.d.ts +10 -0
- package/dist/kernel/loop.d.ts.map +1 -1
- package/dist/kernel/loop.js +6 -2
- package/dist/kernel/loop.js.map +1 -1
- package/dist/kernel/reducer.d.ts.map +1 -1
- package/dist/kernel/reducer.js +17 -1
- package/dist/kernel/reducer.js.map +1 -1
- package/dist/kernel/state.d.ts +17 -4
- package/dist/kernel/state.d.ts.map +1 -1
- package/dist/kernel/state.js +2 -0
- package/dist/kernel/state.js.map +1 -1
- package/dist/pi/context-budget.d.ts +8 -0
- package/dist/pi/context-budget.d.ts.map +1 -0
- package/dist/pi/context-budget.js +62 -0
- package/dist/pi/context-budget.js.map +1 -0
- package/dist/pi/pi-runtime-adapter.d.ts +38 -3
- package/dist/pi/pi-runtime-adapter.d.ts.map +1 -1
- package/dist/pi/pi-runtime-adapter.js +551 -56
- package/dist/pi/pi-runtime-adapter.js.map +1 -1
- package/dist/plugins/bundled-skills.d.ts +56 -0
- package/dist/plugins/bundled-skills.d.ts.map +1 -0
- package/dist/plugins/bundled-skills.js +243 -0
- package/dist/plugins/bundled-skills.js.map +1 -0
- package/dist/plugins/extension-manager.d.ts +23 -1
- package/dist/plugins/extension-manager.d.ts.map +1 -1
- package/dist/plugins/extension-manager.js +117 -0
- package/dist/plugins/extension-manager.js.map +1 -1
- package/dist/plugins/types.d.ts +29 -0
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/workspace-skills.d.ts +36 -0
- package/dist/plugins/workspace-skills.d.ts.map +1 -0
- package/dist/plugins/workspace-skills.js +309 -0
- package/dist/plugins/workspace-skills.js.map +1 -0
- package/dist/policy/policy.d.ts.map +1 -1
- package/dist/policy/policy.js +22 -3
- package/dist/policy/policy.js.map +1 -1
- package/dist/protocol/client.d.ts +1 -0
- package/dist/protocol/client.d.ts.map +1 -1
- package/dist/protocol/client.js +49 -24
- package/dist/protocol/client.js.map +1 -1
- package/dist/protocol/limits.d.ts +69 -0
- package/dist/protocol/limits.d.ts.map +1 -0
- package/dist/protocol/limits.js +166 -0
- package/dist/protocol/limits.js.map +1 -0
- package/dist/protocol/schema.d.ts +6 -0
- package/dist/protocol/schema.d.ts.map +1 -1
- package/dist/protocol/schema.js.map +1 -1
- package/dist/protocol/server-frame-codec.d.ts +18 -0
- package/dist/protocol/server-frame-codec.d.ts.map +1 -0
- package/dist/protocol/server-frame-codec.js +211 -0
- package/dist/protocol/server-frame-codec.js.map +1 -0
- package/dist/service/legacy-daemon.d.ts +6 -0
- package/dist/service/legacy-daemon.d.ts.map +1 -0
- package/dist/service/legacy-daemon.js +42 -0
- package/dist/service/legacy-daemon.js.map +1 -0
- package/dist/service/service-launch.d.ts +10 -0
- package/dist/service/service-launch.d.ts.map +1 -0
- package/dist/service/service-launch.js +56 -0
- package/dist/service/service-launch.js.map +1 -0
- package/dist/service/system-service.d.ts +57 -0
- package/dist/service/system-service.d.ts.map +1 -0
- package/dist/service/system-service.js +263 -0
- package/dist/service/system-service.js.map +1 -0
- package/dist/sessions/session-catalog.d.ts +45 -0
- package/dist/sessions/session-catalog.d.ts.map +1 -0
- package/dist/sessions/session-catalog.js +270 -0
- package/dist/sessions/session-catalog.js.map +1 -0
- package/dist/sessions/session-file.d.ts +12 -0
- package/dist/sessions/session-file.d.ts.map +1 -0
- package/dist/sessions/session-file.js +47 -0
- package/dist/sessions/session-file.js.map +1 -0
- package/dist/settings/compaction-settings.d.ts +10 -0
- package/dist/settings/compaction-settings.d.ts.map +1 -0
- package/dist/settings/compaction-settings.js +8 -0
- package/dist/settings/compaction-settings.js.map +1 -0
- package/dist/settings/indexing-settings-service.d.ts +16 -0
- package/dist/settings/indexing-settings-service.d.ts.map +1 -0
- package/dist/settings/indexing-settings-service.js +57 -0
- package/dist/settings/indexing-settings-service.js.map +1 -0
- package/dist/settings/indexing-settings.d.ts +30 -0
- package/dist/settings/indexing-settings.d.ts.map +1 -0
- package/dist/settings/indexing-settings.js +58 -0
- package/dist/settings/indexing-settings.js.map +1 -0
- package/dist/settings/llm-settings-service.d.ts +6 -0
- package/dist/settings/llm-settings-service.d.ts.map +1 -1
- package/dist/settings/llm-settings-service.js +137 -12
- package/dist/settings/llm-settings-service.js.map +1 -1
- package/dist/settings/llm-settings.d.ts +68 -2
- package/dist/settings/llm-settings.d.ts.map +1 -1
- package/dist/settings/llm-settings.js +119 -17
- package/dist/settings/llm-settings.js.map +1 -1
- package/dist/settings/model-api.d.ts +10 -0
- package/dist/settings/model-api.d.ts.map +1 -0
- package/dist/settings/model-api.js +10 -0
- package/dist/settings/model-api.js.map +1 -0
- package/dist/storage/home.d.ts +1 -0
- package/dist/storage/home.d.ts.map +1 -1
- package/dist/storage/home.js +37 -2
- package/dist/storage/home.js.map +1 -1
- package/dist/tools/core-tools.d.ts +2 -0
- package/dist/tools/core-tools.d.ts.map +1 -1
- package/dist/tools/core-tools.js +91 -1
- package/dist/tools/core-tools.js.map +1 -1
- package/dist/ui/shared/activity.d.ts +2 -0
- package/dist/ui/shared/activity.d.ts.map +1 -1
- package/dist/ui/shared/activity.js +18 -4
- package/dist/ui/shared/activity.js.map +1 -1
- package/dist/ui/shared/full-message.d.ts +22 -0
- package/dist/ui/shared/full-message.d.ts.map +1 -0
- package/dist/ui/shared/full-message.js +40 -0
- package/dist/ui/shared/full-message.js.map +1 -0
- package/dist/ui/shared/local-links.d.ts +7 -0
- package/dist/ui/shared/local-links.d.ts.map +1 -0
- package/dist/ui/shared/local-links.js +24 -0
- package/dist/ui/shared/local-links.js.map +1 -0
- package/dist/ui/shared/markdown-stream.d.ts +25 -0
- package/dist/ui/shared/markdown-stream.d.ts.map +1 -0
- package/dist/ui/shared/markdown-stream.js +35 -0
- package/dist/ui/shared/markdown-stream.js.map +1 -0
- package/dist/ui/shared/markdown.d.ts +70 -0
- package/dist/ui/shared/markdown.d.ts.map +1 -0
- package/dist/ui/shared/markdown.js +434 -0
- package/dist/ui/shared/markdown.js.map +1 -0
- package/dist/ui/shared/presentation.d.ts +10 -2
- package/dist/ui/shared/presentation.d.ts.map +1 -1
- package/dist/ui/shared/presentation.js +83 -22
- package/dist/ui/shared/presentation.js.map +1 -1
- package/dist/ui/shared/protocol-state.d.ts +19 -0
- package/dist/ui/shared/protocol-state.d.ts.map +1 -1
- package/dist/ui/shared/protocol-state.js +188 -26
- package/dist/ui/shared/protocol-state.js.map +1 -1
- package/dist/ui/shared/virtual-list.d.ts +34 -0
- package/dist/ui/shared/virtual-list.d.ts.map +1 -0
- package/dist/ui/shared/virtual-list.js +143 -0
- package/dist/ui/shared/virtual-list.js.map +1 -0
- package/dist/ui/tui/commands.d.ts +1 -1
- package/dist/ui/tui/commands.d.ts.map +1 -1
- package/dist/ui/tui/commands.js +8 -3
- package/dist/ui/tui/commands.js.map +1 -1
- package/dist/ui/tui/store.d.ts +22 -1
- package/dist/ui/tui/store.d.ts.map +1 -1
- package/dist/ui/tui/store.js +50 -3
- package/dist/ui/tui/store.js.map +1 -1
- package/dist/ui/tui/tui-app.d.ts.map +1 -1
- package/dist/ui/tui/tui-app.js +231 -31
- package/dist/ui/tui/tui-app.js.map +1 -1
- package/dist/ui/tui/view.d.ts +7 -0
- package/dist/ui/tui/view.d.ts.map +1 -1
- package/dist/ui/tui/view.js +161 -57
- package/dist/ui/tui/view.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +13 -2
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
/// <reference lib="DOM" />
|
|
2
|
+
import { alignArticleDraft, articleChangeCounts, articleLineChangeCounts, diffArticleBlocks, diffArticleLines, } from "../../articles/diff.js";
|
|
3
|
+
import { parseArticleOpenReceipt, parseArticleReceipt } from "../../articles/receipt.js";
|
|
4
|
+
import { readFullMessage } from "../../ui/shared/full-message.js";
|
|
5
|
+
import { articleShareForWidth, articleWidthForShare, clampArticleWidth } from "./article-split.js";
|
|
6
|
+
import { reconcileChildren } from "./dom-reconcile.js";
|
|
7
|
+
import { appendMarkdown } from "./markdown-view.js";
|
|
8
|
+
import { animateArticleWidth, collapseArticleRemovals, pulseArticleChanges } from "./motion.js";
|
|
9
|
+
/** New blocks that pulse in at once. Past this the page flashes rather than draws attention. */
|
|
10
|
+
const MAX_PULSED_BLOCKS = 16;
|
|
11
|
+
/**
|
|
12
|
+
* Looking up the session's articles can start before the daemon answers. Frames
|
|
13
|
+
* stop arriving once the session is idle, so a failed lookup has to come back on
|
|
14
|
+
* its own rather than wait for one that may never come.
|
|
15
|
+
*/
|
|
16
|
+
const REOPEN_RETRY_MS = 300;
|
|
17
|
+
const REOPEN_RETRY_LIMIT = 8;
|
|
18
|
+
/** Recover a lost transient notification or failed read while a call is streaming. */
|
|
19
|
+
const PREVIEW_RETRY_MS = 300;
|
|
20
|
+
/** Blocks kept for reuse, on screen or not. Bounded so a long revision history cannot grow it forever. */
|
|
21
|
+
const BLOCK_POOL_SIZE = 512;
|
|
22
|
+
/**
|
|
23
|
+
* The reader's split, as the document's share of the space it divides with the
|
|
24
|
+
* conversation. The bounds live in `article-split.ts`.
|
|
25
|
+
*/
|
|
26
|
+
const ARTICLE_SHARE_KEY = "pspore.desktop.articleShare";
|
|
27
|
+
/** Keyboard steps for the separator, so the width is reachable without a pointer. */
|
|
28
|
+
const ARTICLE_WIDTH_STEP = 24;
|
|
29
|
+
/**
|
|
30
|
+
* Makes a signature unique among identical siblings.
|
|
31
|
+
*
|
|
32
|
+
* Two identical paragraphs render the same markup, so the key has to say which
|
|
33
|
+
* of them this is; otherwise the second one reuses the first one's node and the
|
|
34
|
+
* list collapses.
|
|
35
|
+
*/
|
|
36
|
+
function keyOf(seen, signature) {
|
|
37
|
+
const occurrence = (seen.get(signature) ?? 0) + 1;
|
|
38
|
+
seen.set(signature, occurrence);
|
|
39
|
+
return `${occurrence}\u0000${signature}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Rendered markdown blocks, keyed by what they draw so an unchanged one keeps
|
|
43
|
+
* its element. Whether a block is a change is carried separately: it is an
|
|
44
|
+
* attribute on the node, not part of its identity, so a block that was new in
|
|
45
|
+
* one version and settled in the next is not rebuilt to say so.
|
|
46
|
+
*/
|
|
47
|
+
function blockEntries(blocks) {
|
|
48
|
+
const seen = new Map();
|
|
49
|
+
return blocks.map((block) => ({
|
|
50
|
+
key: keyOf(seen, `block\u0000${block.source}`),
|
|
51
|
+
changed: block.changed,
|
|
52
|
+
build: () => {
|
|
53
|
+
const wrap = document.createElement("div");
|
|
54
|
+
wrap.className = "article-block";
|
|
55
|
+
appendMarkdown(wrap, block.source);
|
|
56
|
+
return wrap;
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
/** One line of a revision: a gutter mark and the text, with the rewrite marked inside it. */
|
|
61
|
+
function diffLineNode(line) {
|
|
62
|
+
const row = document.createElement("div");
|
|
63
|
+
row.className = "article-diff-line";
|
|
64
|
+
row.dataset["change"] = line.kind;
|
|
65
|
+
const gutter = document.createElement("span");
|
|
66
|
+
gutter.className = "article-diff-gutter";
|
|
67
|
+
gutter.setAttribute("aria-hidden", "true");
|
|
68
|
+
gutter.textContent = String(line.number);
|
|
69
|
+
const mark = document.createElement("span");
|
|
70
|
+
mark.className = "article-diff-mark";
|
|
71
|
+
mark.textContent = line.kind === "add" ? "+" : line.kind === "remove" ? "−" : " ";
|
|
72
|
+
const text = document.createElement("span");
|
|
73
|
+
text.className = "article-diff-text";
|
|
74
|
+
const segments = line.segments;
|
|
75
|
+
if (segments && segments.length > 0) {
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
if (segment.kind === "same") {
|
|
78
|
+
text.append(document.createTextNode(segment.text));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const marked = document.createElement(segment.kind === "add" ? "ins" : "del");
|
|
82
|
+
marked.textContent = segment.text;
|
|
83
|
+
text.append(marked);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
text.textContent = line.text;
|
|
88
|
+
}
|
|
89
|
+
row.append(gutter, mark, text);
|
|
90
|
+
return row;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The document panel. Chat keeps a one-line receipt; this is where the article
|
|
94
|
+
* is rendered, revised, and exported.
|
|
95
|
+
*
|
|
96
|
+
* While the model is still streaming the tool arguments the panel renders that
|
|
97
|
+
* draft. When the tool commits a version, the new blocks pulse against the
|
|
98
|
+
* previous saved text. "对照上一版" leaves the removed blocks on screen.
|
|
99
|
+
*/
|
|
100
|
+
export class ArticlePanel {
|
|
101
|
+
#sessionId;
|
|
102
|
+
#dispatch;
|
|
103
|
+
#exportArticle;
|
|
104
|
+
#onLayout;
|
|
105
|
+
#panel;
|
|
106
|
+
#title;
|
|
107
|
+
#version;
|
|
108
|
+
#versions;
|
|
109
|
+
#summary;
|
|
110
|
+
#stage;
|
|
111
|
+
#render;
|
|
112
|
+
#status;
|
|
113
|
+
#viewButtons;
|
|
114
|
+
#reopen;
|
|
115
|
+
#exportButtons;
|
|
116
|
+
#resize;
|
|
117
|
+
/** The column the document divides the space with. Its left edge never moves with the split. */
|
|
118
|
+
#conversation;
|
|
119
|
+
#open = false;
|
|
120
|
+
#view = "read";
|
|
121
|
+
/**
|
|
122
|
+
* Blocks keyed by what they render, including ones not on screen right now.
|
|
123
|
+
*
|
|
124
|
+
* A draft passes through partial forms of almost every block on its way to the
|
|
125
|
+
* finished version. Dropping an element the moment it stops matching would
|
|
126
|
+
* mean rebuilding it a second later when the block is whole again, so recently
|
|
127
|
+
* detached blocks stay here until the pool is full.
|
|
128
|
+
*/
|
|
129
|
+
#nodes = new Map();
|
|
130
|
+
#paintSequence = 0;
|
|
131
|
+
/** The view the reader chose, restored once a draft becomes a version again. */
|
|
132
|
+
#preferredView = "read";
|
|
133
|
+
#writing = false;
|
|
134
|
+
#waitingForDraft = false;
|
|
135
|
+
#writingKey = "";
|
|
136
|
+
/** Preview events seen for the call being written, and how many of them are already drawn. */
|
|
137
|
+
#previewAsked = 0;
|
|
138
|
+
#previewShown = 0;
|
|
139
|
+
/** Token of the read loop that is running, or -1. */
|
|
140
|
+
#previewPump = -1;
|
|
141
|
+
#previewRetry;
|
|
142
|
+
#lastDraft;
|
|
143
|
+
/** Keep the block being written in view, until the reader scrolls on their own. */
|
|
144
|
+
#followPen = true;
|
|
145
|
+
/** A call the reader closed the panel on. Its drafts stay out of the way; its version still lands. */
|
|
146
|
+
#dismissedKey = "";
|
|
147
|
+
#token = 0;
|
|
148
|
+
#session = "";
|
|
149
|
+
#articleId = "";
|
|
150
|
+
#loadedVersion = 0;
|
|
151
|
+
#titleText = "";
|
|
152
|
+
/** Text of the version this one replaced, when we watched that hand-off. */
|
|
153
|
+
#baseline = "";
|
|
154
|
+
#committed = "";
|
|
155
|
+
#versionsKnown = [];
|
|
156
|
+
#reopenId = "";
|
|
157
|
+
#reopenTitle = "";
|
|
158
|
+
/** Session whose article list has been read. "" means the lookup still owes us an answer. */
|
|
159
|
+
#reopenSession = "";
|
|
160
|
+
#reopenInFlight = false;
|
|
161
|
+
#reopenRetries = 0;
|
|
162
|
+
/** A session change that arrived while a lookup was already running. */
|
|
163
|
+
#reopenPending = false;
|
|
164
|
+
#exporting = false;
|
|
165
|
+
/** True between asking to close and the width reaching zero. */
|
|
166
|
+
#closing = false;
|
|
167
|
+
/** Width the grid is laid out at right now, including mid-animation. */
|
|
168
|
+
#shownWidth = 0;
|
|
169
|
+
/** The open or close in progress. Only one may write the width at a time. */
|
|
170
|
+
#widthAnimation;
|
|
171
|
+
/** Share of the space the reader dragged the document to, kept across opens; undefined until they choose one. */
|
|
172
|
+
#storedShare;
|
|
173
|
+
constructor(options) {
|
|
174
|
+
this.#sessionId = options.sessionId;
|
|
175
|
+
this.#dispatch = options.dispatch;
|
|
176
|
+
this.#exportArticle = options.exportArticle;
|
|
177
|
+
this.#onLayout = options.onLayout;
|
|
178
|
+
this.#panel = requiredElement("article-panel");
|
|
179
|
+
this.#title = requiredElement("article-title");
|
|
180
|
+
this.#version = requiredElement("article-version");
|
|
181
|
+
this.#versions = requiredElement("article-versions");
|
|
182
|
+
this.#summary = requiredElement("article-diff-summary");
|
|
183
|
+
this.#stage = requiredElement("article-stage");
|
|
184
|
+
this.#render = requiredElement("article-render");
|
|
185
|
+
this.#status = requiredElement("article-status");
|
|
186
|
+
this.#viewButtons = Array.from(requiredElement("article-views").querySelectorAll("[data-view]"));
|
|
187
|
+
this.#reopen = requiredButton("open-article-button");
|
|
188
|
+
this.#syncViews(false);
|
|
189
|
+
requiredButton("article-close").addEventListener("click", () => this.close());
|
|
190
|
+
for (const button of this.#viewButtons) {
|
|
191
|
+
button.addEventListener("click", () => {
|
|
192
|
+
const view = button.dataset["view"];
|
|
193
|
+
if (view !== "read" && view !== "token" && view !== "line")
|
|
194
|
+
return;
|
|
195
|
+
if (view === this.#view)
|
|
196
|
+
return;
|
|
197
|
+
this.#view = view;
|
|
198
|
+
this.#preferredView = view;
|
|
199
|
+
this.#syncViews(this.#baseline !== "");
|
|
200
|
+
this.#syncSummary(this.#baseline, this.#loadedVersion);
|
|
201
|
+
if (this.#articleId)
|
|
202
|
+
this.#paint(this.#baseline, false, "top");
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
this.#reopen.addEventListener("click", () => {
|
|
206
|
+
if (this.#reopenId)
|
|
207
|
+
void this.open(this.#reopenId);
|
|
208
|
+
});
|
|
209
|
+
// Scrolling or clicking into the document is the reader taking over from the pen.
|
|
210
|
+
for (const type of ["wheel", "touchstart", "pointerdown", "keydown"]) {
|
|
211
|
+
this.#stage.addEventListener(type, () => {
|
|
212
|
+
this.#followPen = false;
|
|
213
|
+
}, { passive: true });
|
|
214
|
+
}
|
|
215
|
+
this.#resize = requiredElement("article-resize");
|
|
216
|
+
const conversation = document.querySelector(".conversation");
|
|
217
|
+
if (!conversation)
|
|
218
|
+
throw new Error("Missing .conversation");
|
|
219
|
+
this.#conversation = conversation;
|
|
220
|
+
this.#storedShare = readStoredShare();
|
|
221
|
+
this.#applyWidth(0);
|
|
222
|
+
this.#bindResize();
|
|
223
|
+
this.#exportButtons = Array.from(this.#panel.querySelectorAll("[data-export]"));
|
|
224
|
+
for (const button of this.#exportButtons) {
|
|
225
|
+
button.addEventListener("click", () => {
|
|
226
|
+
const format = button.dataset["export"];
|
|
227
|
+
if (format === "txt" || format === "md" || format === "docx" || format === "pdf")
|
|
228
|
+
void this.#export(format);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
isOpen() {
|
|
233
|
+
return this.#open;
|
|
234
|
+
}
|
|
235
|
+
isWriting() {
|
|
236
|
+
return this.#writing;
|
|
237
|
+
}
|
|
238
|
+
writingKey() {
|
|
239
|
+
return this.#writingKey;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Called whenever the selected session may have changed, including on the
|
|
243
|
+
* frames that follow a reconnect. The session is often selected for the user
|
|
244
|
+
* — restored at startup, or picked once the daemon answers — so waiting for a
|
|
245
|
+
* click would leave the panel unaware of the articles that session owns. A
|
|
246
|
+
* lookup that could not run, because the daemon was not connected yet, is
|
|
247
|
+
* retried on the next frame instead of leaving the panel silent for good.
|
|
248
|
+
*/
|
|
249
|
+
onSessionChanged() {
|
|
250
|
+
const sessionId = this.#sessionId();
|
|
251
|
+
if (sessionId !== this.#session) {
|
|
252
|
+
this.#session = sessionId;
|
|
253
|
+
this.#token += 1;
|
|
254
|
+
this.#writing = false;
|
|
255
|
+
this.#waitingForDraft = false;
|
|
256
|
+
this.#dismissedKey = "";
|
|
257
|
+
this.#reopenSession = "";
|
|
258
|
+
this.#reopenRetries = 0;
|
|
259
|
+
this.#forget();
|
|
260
|
+
this.close();
|
|
261
|
+
}
|
|
262
|
+
if (!sessionId || this.#reopenSession === sessionId)
|
|
263
|
+
return;
|
|
264
|
+
if (this.#reopenInFlight) {
|
|
265
|
+
// The lookup in flight is for the session we have just left. Remembering
|
|
266
|
+
// this is what keeps the change from being dropped: the frames that carry
|
|
267
|
+
// it are the same ones that stop arriving once the session settles.
|
|
268
|
+
this.#reopenPending = true;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
void this.#refreshReopen(sessionId);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Drops every trace of the document that was open.
|
|
275
|
+
*
|
|
276
|
+
* The panel keeps the current text to diff the next version against, and a
|
|
277
|
+
* pool of rendered blocks to reuse. Both belong to one article in one session:
|
|
278
|
+
* carried across, they turn into the previous session's article appearing
|
|
279
|
+
* underneath a new one being written.
|
|
280
|
+
*/
|
|
281
|
+
#forget() {
|
|
282
|
+
this.#paintSequence += 1;
|
|
283
|
+
this.#articleId = "";
|
|
284
|
+
this.#loadedVersion = 0;
|
|
285
|
+
this.#titleText = "";
|
|
286
|
+
this.#baseline = "";
|
|
287
|
+
this.#committed = "";
|
|
288
|
+
this.#versionsKnown = [];
|
|
289
|
+
this.#reopenId = "";
|
|
290
|
+
this.#reopenTitle = "";
|
|
291
|
+
this.#nodes = new Map();
|
|
292
|
+
this.#render.replaceChildren();
|
|
293
|
+
this.#versions.replaceChildren();
|
|
294
|
+
this.#summary.hidden = true;
|
|
295
|
+
this.#status.textContent = "";
|
|
296
|
+
this.#version.textContent = "";
|
|
297
|
+
this.#syncExports();
|
|
298
|
+
this.#title.textContent = "文稿";
|
|
299
|
+
this.#view = "read";
|
|
300
|
+
this.#syncViews(false);
|
|
301
|
+
}
|
|
302
|
+
onEvent(event) {
|
|
303
|
+
const payload = asRecord(event.payload);
|
|
304
|
+
if (stringValue(payload["sessionId"]) !== this.#sessionId())
|
|
305
|
+
return;
|
|
306
|
+
if (event.type === "run.settled" && this.#waitingForDraft) {
|
|
307
|
+
this.#waitingForDraft = false;
|
|
308
|
+
this.#status.textContent = "尚未写入新内容";
|
|
309
|
+
}
|
|
310
|
+
if ((event.type === "run.settled" || event.type === "runtime.error") && this.#writing)
|
|
311
|
+
this.#onRejected();
|
|
312
|
+
if (event.type === "article.preview") {
|
|
313
|
+
const toolCallId = stringValue(payload["toolCallId"]);
|
|
314
|
+
if (!toolCallId)
|
|
315
|
+
return;
|
|
316
|
+
this.#onPreview(toolCallId, stringValue(payload["title"]));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (event.type !== "tool.completed" || stringValue(payload["toolName"]) !== "article" || payload["isError"] === true) {
|
|
320
|
+
if (event.type === "tool.completed" && stringValue(payload["toolName"]) === "article" && payload["isError"] === true) {
|
|
321
|
+
this.#onRejected();
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const text = toolResultText(payload["result"]);
|
|
326
|
+
const opened = parseArticleOpenReceipt(text);
|
|
327
|
+
if (opened) {
|
|
328
|
+
void this.#onOpened(opened);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const receipt = parseArticleReceipt(text);
|
|
332
|
+
if (!receipt)
|
|
333
|
+
return;
|
|
334
|
+
void this.#onCommitted(receipt.id, receipt.version, receipt.title);
|
|
335
|
+
}
|
|
336
|
+
/** The short preparation call completes before the next, possibly buffered, LLM response. */
|
|
337
|
+
async #onOpened(receipt) {
|
|
338
|
+
const token = ++this.#token;
|
|
339
|
+
this.#writing = false;
|
|
340
|
+
this.#waitingForDraft = true;
|
|
341
|
+
if (!receipt.id || receipt.id !== this.#articleId)
|
|
342
|
+
this.#forget();
|
|
343
|
+
this.#titleText = receipt.title;
|
|
344
|
+
this.#show(receipt.title);
|
|
345
|
+
this.#status.textContent = "文稿已打开,等待正文…";
|
|
346
|
+
if (receipt.id) {
|
|
347
|
+
const loaded = await this.#load(receipt.id, receipt.version);
|
|
348
|
+
if (token !== this.#token)
|
|
349
|
+
return;
|
|
350
|
+
if (loaded)
|
|
351
|
+
this.#applyLoaded(loaded, false, "top");
|
|
352
|
+
this.#status.textContent = this.#waitingForDraft ? "文稿已打开,等待修改…" : "尚未写入新内容";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async open(id, version) {
|
|
356
|
+
const token = ++this.#token;
|
|
357
|
+
this.#show(this.#titleText || "文稿");
|
|
358
|
+
this.#status.textContent = "正在载入文稿…";
|
|
359
|
+
const loaded = await this.#load(id, version);
|
|
360
|
+
if (token !== this.#token || !loaded) {
|
|
361
|
+
if (token === this.#token)
|
|
362
|
+
this.#status.textContent = "没有找到文稿";
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// Opening a document, or stepping to another version, is a request to read
|
|
366
|
+
// it — so it starts at the title, not at whatever the last line happens to be.
|
|
367
|
+
this.#applyLoaded(loaded, true, "top");
|
|
368
|
+
}
|
|
369
|
+
close() {
|
|
370
|
+
this.#waitingForDraft = false;
|
|
371
|
+
if ((!this.#open && this.#panel.hidden) || this.#closing) {
|
|
372
|
+
this.#syncReopen();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Closing while a draft streams is a choice about that draft: the next
|
|
376
|
+
// preview must not throw the panel straight back open.
|
|
377
|
+
if (this.#writing) {
|
|
378
|
+
this.#dismissedKey = this.#writingKey;
|
|
379
|
+
this.#token += 1;
|
|
380
|
+
}
|
|
381
|
+
this.#open = false;
|
|
382
|
+
this.#writing = false;
|
|
383
|
+
this.#closing = true;
|
|
384
|
+
// `data-article` stays set until the animation ends: it is what makes the
|
|
385
|
+
// grid read the width being animated away. The width is left at zero, so
|
|
386
|
+
// the next open — a reopen, or a document in another session — arrives the
|
|
387
|
+
// same way the first one did instead of appearing at full size.
|
|
388
|
+
this.#animateWidth(0, () => {
|
|
389
|
+
this.#closing = false;
|
|
390
|
+
this.#panel.hidden = true;
|
|
391
|
+
delete document.documentElement.dataset["article"];
|
|
392
|
+
this.#onLayout();
|
|
393
|
+
});
|
|
394
|
+
this.#onLayout();
|
|
395
|
+
this.#syncReopen();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* A draft moved on. The panel opens on the first one — before any text has
|
|
399
|
+
* been read — and then keeps exactly one read in flight, always for the
|
|
400
|
+
* newest text.
|
|
401
|
+
*
|
|
402
|
+
* Every event used to start its own read and void the one before it. When a
|
|
403
|
+
* read and its repaint take longer than the gap between events, as on a slow
|
|
404
|
+
* machine, every read was voided by the next event and nothing was drawn
|
|
405
|
+
* until the model stopped writing.
|
|
406
|
+
*/
|
|
407
|
+
#onPreview(toolCallId, title) {
|
|
408
|
+
if (toolCallId === this.#dismissedKey)
|
|
409
|
+
return;
|
|
410
|
+
this.#waitingForDraft = false;
|
|
411
|
+
if (!this.#writing || toolCallId !== this.#writingKey) {
|
|
412
|
+
// A new call supersedes whatever was being loaded or written before it.
|
|
413
|
+
this.#token += 1;
|
|
414
|
+
this.#writing = true;
|
|
415
|
+
this.#writingKey = toolCallId;
|
|
416
|
+
this.#previewAsked = 0;
|
|
417
|
+
this.#previewShown = 0;
|
|
418
|
+
this.#lastDraft = undefined;
|
|
419
|
+
this.#followPen = true;
|
|
420
|
+
// A draft has no saved version behind it, so there is nothing to compare it
|
|
421
|
+
// against until it lands. The reader's choice comes back with the version.
|
|
422
|
+
this.#view = "read";
|
|
423
|
+
this.#syncViews(false);
|
|
424
|
+
this.#status.textContent = "正在撰写…";
|
|
425
|
+
}
|
|
426
|
+
this.#previewAsked += 1;
|
|
427
|
+
this.#show(title || this.#titleText || "正在撰写");
|
|
428
|
+
this.#pumpPreview();
|
|
429
|
+
}
|
|
430
|
+
#pumpPreview() {
|
|
431
|
+
const token = this.#token;
|
|
432
|
+
if (this.#previewPump === token)
|
|
433
|
+
return;
|
|
434
|
+
window.clearTimeout(this.#previewRetry);
|
|
435
|
+
this.#previewPump = token;
|
|
436
|
+
void this.#drainPreview(token).finally(() => {
|
|
437
|
+
if (this.#previewPump === token)
|
|
438
|
+
this.#previewPump = -1;
|
|
439
|
+
if (token !== this.#token || !this.#writing)
|
|
440
|
+
return;
|
|
441
|
+
// Preview events are transient and may be shed under backpressure. A
|
|
442
|
+
// single delayed read also recovers a failure after the last notification.
|
|
443
|
+
this.#previewRetry = window.setTimeout(() => {
|
|
444
|
+
if (token !== this.#token || !this.#writing)
|
|
445
|
+
return;
|
|
446
|
+
this.#previewAsked += 1;
|
|
447
|
+
this.#pumpPreview();
|
|
448
|
+
}, PREVIEW_RETRY_MS);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async #drainPreview(token) {
|
|
452
|
+
while (token === this.#token && this.#previewShown < this.#previewAsked) {
|
|
453
|
+
const asked = this.#previewAsked;
|
|
454
|
+
const draft = await this.#readDraft(this.#writingKey);
|
|
455
|
+
if (token !== this.#token)
|
|
456
|
+
return;
|
|
457
|
+
this.#previewShown = asked;
|
|
458
|
+
if (!draft)
|
|
459
|
+
continue;
|
|
460
|
+
const previous = this.#lastDraft;
|
|
461
|
+
if (previous && previous.text === draft.text && previous.title === draft.title
|
|
462
|
+
&& previous.articleId === draft.articleId && previous.baseVersion === draft.baseVersion
|
|
463
|
+
&& previous.whole === draft.whole && previous.edits === draft.edits)
|
|
464
|
+
continue;
|
|
465
|
+
// An update is drawn over the version it revises. If the panel is showing
|
|
466
|
+
// another article, or another version, that version goes up first.
|
|
467
|
+
if (draft.articleId !== undefined
|
|
468
|
+
&& draft.baseVersion !== undefined
|
|
469
|
+
&& !(this.#articleId === draft.articleId && this.#loadedVersion === draft.baseVersion)) {
|
|
470
|
+
const base = await this.#load(draft.articleId, draft.baseVersion);
|
|
471
|
+
if (token !== this.#token)
|
|
472
|
+
return;
|
|
473
|
+
if (base)
|
|
474
|
+
this.#adoptBase(base);
|
|
475
|
+
}
|
|
476
|
+
this.#renderDraft(draft);
|
|
477
|
+
this.#lastDraft = draft;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async #readDraft(toolCallId) {
|
|
481
|
+
let head = {};
|
|
482
|
+
let text;
|
|
483
|
+
try {
|
|
484
|
+
text = await this.#readSlices(async (offset, limit) => {
|
|
485
|
+
const slice = await this.#dispatch({
|
|
486
|
+
action: "article.preview",
|
|
487
|
+
sessionId: this.#sessionId(),
|
|
488
|
+
toolCallId,
|
|
489
|
+
offset,
|
|
490
|
+
limit,
|
|
491
|
+
});
|
|
492
|
+
if (offset === 0)
|
|
493
|
+
head = asRecord(slice);
|
|
494
|
+
return slice;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// The next event reads again; the version itself arrives with the result.
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
if (text === undefined)
|
|
502
|
+
return undefined;
|
|
503
|
+
const title = stringValue(head["title"]);
|
|
504
|
+
const articleId = stringValue(head["articleId"]);
|
|
505
|
+
return {
|
|
506
|
+
text,
|
|
507
|
+
whole: head["whole"] === true,
|
|
508
|
+
edits: typeof head["edits"] === "number" ? head["edits"] : 0,
|
|
509
|
+
...(title ? { title } : {}),
|
|
510
|
+
...(articleId ? { articleId } : {}),
|
|
511
|
+
...(typeof head["baseVersion"] === "number" ? { baseVersion: head["baseVersion"] } : {}),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/** Puts the version an update revises on screen, without the hand-off animation. */
|
|
515
|
+
#adoptBase(base) {
|
|
516
|
+
if (this.#articleId !== base.id)
|
|
517
|
+
this.#forget();
|
|
518
|
+
this.#articleId = base.id;
|
|
519
|
+
this.#loadedVersion = base.version;
|
|
520
|
+
this.#titleText = base.title;
|
|
521
|
+
this.#versionsKnown = [...base.versions];
|
|
522
|
+
this.#reopenId = base.id;
|
|
523
|
+
this.#reopenTitle = base.title;
|
|
524
|
+
this.#baseline = "";
|
|
525
|
+
this.#committed = base.text;
|
|
526
|
+
this.#version.textContent = `v${base.version}`;
|
|
527
|
+
this.#paintVersions();
|
|
528
|
+
}
|
|
529
|
+
#renderDraft(draft) {
|
|
530
|
+
// Only a draft of the article already on screen can be shown against it.
|
|
531
|
+
// A draft with no id is a brand new document, and one with a different id
|
|
532
|
+
// belongs to another document — aligning either against what is displayed
|
|
533
|
+
// would leave the wrong article underneath it.
|
|
534
|
+
const baseline = draft.articleId !== undefined && draft.articleId === this.#articleId ? this.#committed : "";
|
|
535
|
+
if (baseline === "" && this.#articleId !== "")
|
|
536
|
+
this.#forget();
|
|
537
|
+
if (draft.articleId)
|
|
538
|
+
this.#articleId = draft.articleId;
|
|
539
|
+
if (draft.title)
|
|
540
|
+
this.#titleText = draft.title;
|
|
541
|
+
this.#title.textContent = this.#titleText || "正在撰写";
|
|
542
|
+
this.#renderLive(draft.text, baseline, draft.whole);
|
|
543
|
+
this.#status.textContent = !draft.whole
|
|
544
|
+
? `${draft.text.length} 字 · 撰写中`
|
|
545
|
+
: draft.edits > 0 ? `修改中 · 已改 ${draft.edits} 处` : "修改中…";
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The call failed, so what the panel drew from its arguments is a version
|
|
549
|
+
* that will never exist; the saved one goes back up.
|
|
550
|
+
*/
|
|
551
|
+
#onRejected() {
|
|
552
|
+
this.#waitingForDraft = false;
|
|
553
|
+
if (this.#writing) {
|
|
554
|
+
this.#token += 1;
|
|
555
|
+
this.#writing = false;
|
|
556
|
+
if (this.#loadedVersion > 0) {
|
|
557
|
+
this.#view = this.#preferredView;
|
|
558
|
+
this.#syncViews(this.#baseline !== "");
|
|
559
|
+
this.#paint(this.#baseline, false, "keep");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
this.#status.textContent = "这一版没有写入";
|
|
563
|
+
}
|
|
564
|
+
async #onCommitted(id, version, title) {
|
|
565
|
+
const token = ++this.#token;
|
|
566
|
+
this.#writing = false;
|
|
567
|
+
this.#waitingForDraft = false;
|
|
568
|
+
this.#show(title || this.#titleText || "文稿");
|
|
569
|
+
// Streaming only kept the draft, so an update that lands without the prior
|
|
570
|
+
// version on screen still diffs against that saved text.
|
|
571
|
+
if (!(this.#articleId === id && this.#loadedVersion === version - 1) && version > 1) {
|
|
572
|
+
const previous = await this.#load(id, version - 1);
|
|
573
|
+
if (token !== this.#token)
|
|
574
|
+
return;
|
|
575
|
+
if (previous) {
|
|
576
|
+
this.#articleId = previous.id;
|
|
577
|
+
this.#loadedVersion = previous.version;
|
|
578
|
+
this.#committed = previous.text;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const loaded = await this.#load(id, version);
|
|
582
|
+
if (token !== this.#token || !loaded)
|
|
583
|
+
return;
|
|
584
|
+
this.#applyLoaded(loaded, true, "keep");
|
|
585
|
+
}
|
|
586
|
+
#applyLoaded(loaded, animate, anchor) {
|
|
587
|
+
const sequential = this.#articleId === loaded.id && this.#loadedVersion > 0 && loaded.version === this.#loadedVersion + 1;
|
|
588
|
+
const firstVersion = this.#loadedVersion === 0 && loaded.version === 1;
|
|
589
|
+
const baseline = sequential ? this.#committed : "";
|
|
590
|
+
this.#articleId = loaded.id;
|
|
591
|
+
this.#loadedVersion = loaded.version;
|
|
592
|
+
this.#titleText = loaded.title;
|
|
593
|
+
this.#versionsKnown = [...loaded.versions];
|
|
594
|
+
this.#reopenId = loaded.id;
|
|
595
|
+
this.#reopenTitle = loaded.title;
|
|
596
|
+
this.#baseline = baseline;
|
|
597
|
+
this.#committed = loaded.text;
|
|
598
|
+
this.#title.textContent = loaded.title;
|
|
599
|
+
this.#version.textContent = `v${loaded.version}`;
|
|
600
|
+
this.#paintVersions();
|
|
601
|
+
this.#paint(baseline, animate && (sequential || firstVersion), anchor);
|
|
602
|
+
this.#view = this.#preferredView;
|
|
603
|
+
this.#syncViews(baseline !== "");
|
|
604
|
+
this.#syncSummary(baseline, loaded.version);
|
|
605
|
+
this.#status.textContent = `${loaded.text.length} 字 · 第 ${loaded.version} 版`;
|
|
606
|
+
this.#syncReopen();
|
|
607
|
+
// Reopening a document, or stepping back through the version chips, skips
|
|
608
|
+
// the hand-off the panel would otherwise have watched. Fetching the version
|
|
609
|
+
// it replaced is what makes "对照上一版" mean something there too.
|
|
610
|
+
if (baseline === "" && loaded.version > 1)
|
|
611
|
+
void this.#loadBaseline(loaded.id, loaded.version);
|
|
612
|
+
}
|
|
613
|
+
async #loadBaseline(id, version) {
|
|
614
|
+
const token = this.#token;
|
|
615
|
+
const previous = await this.#load(id, version - 1);
|
|
616
|
+
if (token !== this.#token || !previous)
|
|
617
|
+
return;
|
|
618
|
+
if (this.#articleId !== id || this.#loadedVersion !== version)
|
|
619
|
+
return;
|
|
620
|
+
this.#baseline = previous.text;
|
|
621
|
+
this.#syncSummary(previous.text, version);
|
|
622
|
+
this.#syncViews(true);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* The counts are a caption for the view, not the view itself: paragraphs while
|
|
626
|
+
* reading the article, lines while reading the revision.
|
|
627
|
+
*/
|
|
628
|
+
#syncSummary(baseline, version) {
|
|
629
|
+
if (baseline === "") {
|
|
630
|
+
this.#summary.hidden = true;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (this.#view === "read") {
|
|
634
|
+
const counts = articleChangeCounts(diffArticleBlocks(baseline, this.#committed));
|
|
635
|
+
this.#summary.hidden = counts.added === 0 && counts.removed === 0;
|
|
636
|
+
this.#summary.textContent = `相对第 ${version - 1} 版 +${counts.added} 段 −${counts.removed} 段`;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const counts = articleLineChangeCounts(diffArticleLines(baseline, this.#committed, "line"));
|
|
640
|
+
this.#summary.hidden = counts.added === 0 && counts.removed === 0;
|
|
641
|
+
this.#summary.textContent = `相对第 ${version - 1} 版 +${counts.added} 行 −${counts.removed} 行`;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Draws the current view, keeping whatever the previous version already put on
|
|
645
|
+
* screen.
|
|
646
|
+
*
|
|
647
|
+
* A new version is mostly the old one. Clearing the stage and rebuilding it
|
|
648
|
+
* would throw away the reading position, any selection, and the very blocks
|
|
649
|
+
* the reader is being asked to compare — so blocks are keyed by what they
|
|
650
|
+
* render, unchanged ones keep their nodes, and only the difference moves:
|
|
651
|
+
* dropped blocks fold away first, then new ones pulse in where they belong.
|
|
652
|
+
*/
|
|
653
|
+
#paint(previous, animate, anchor) {
|
|
654
|
+
const sequence = ++this.#paintSequence;
|
|
655
|
+
const entries = this.#view === "read"
|
|
656
|
+
? this.#readEntries(previous)
|
|
657
|
+
: this.#diffEntries(previous, this.#view === "line" ? "line" : "token");
|
|
658
|
+
const wanted = new Set(entries.map((entry) => entry.key));
|
|
659
|
+
const leaving = [...this.#nodes]
|
|
660
|
+
.filter(([key, node]) => !wanted.has(key) && node.isConnected)
|
|
661
|
+
.map(([, node]) => node);
|
|
662
|
+
// A folded block is reused later, so the collapse must not outlive the exit.
|
|
663
|
+
for (const node of leaving)
|
|
664
|
+
node.dataset["leaving"] = "true";
|
|
665
|
+
const commit = () => {
|
|
666
|
+
if (sequence !== this.#paintSequence)
|
|
667
|
+
return;
|
|
668
|
+
this.#commit(entries, animate, anchor);
|
|
669
|
+
};
|
|
670
|
+
if (animate && leaving.length > 0 && leaving.length <= MAX_PULSED_BLOCKS)
|
|
671
|
+
collapseArticleRemovals(leaving, commit);
|
|
672
|
+
else
|
|
673
|
+
commit();
|
|
674
|
+
}
|
|
675
|
+
/** `pen` is the entry being written, kept in view while a draft streams. */
|
|
676
|
+
#commit(entries, animate, anchor, pen = -1) {
|
|
677
|
+
const pinned = anchor === "keep" && this.#pinnedToEnd();
|
|
678
|
+
const children = [];
|
|
679
|
+
const appeared = [];
|
|
680
|
+
for (const entry of entries) {
|
|
681
|
+
const reused = this.#nodes.get(entry.key);
|
|
682
|
+
const node = reused ?? entry.build();
|
|
683
|
+
if (node.dataset["leaving"] !== undefined) {
|
|
684
|
+
// It was folded away on its way out; coming back has to undo that.
|
|
685
|
+
delete node.dataset["leaving"];
|
|
686
|
+
node.removeAttribute("style");
|
|
687
|
+
}
|
|
688
|
+
// What the reader sees appear is what was not on screen a moment ago,
|
|
689
|
+
// whether it was just built or restored from the pool.
|
|
690
|
+
if (!node.isConnected)
|
|
691
|
+
appeared.push(node);
|
|
692
|
+
if (entry.changed === true)
|
|
693
|
+
node.dataset["change"] = "add";
|
|
694
|
+
else if (entry.changed === false)
|
|
695
|
+
delete node.dataset["change"];
|
|
696
|
+
// Re-inserting refreshes the entry's place in the eviction order.
|
|
697
|
+
this.#nodes.delete(entry.key);
|
|
698
|
+
this.#nodes.set(entry.key, node);
|
|
699
|
+
children.push(node);
|
|
700
|
+
}
|
|
701
|
+
// A draft with no text yet is not an empty version; the status line says it is being written.
|
|
702
|
+
if (children.length === 0 && !this.#writing) {
|
|
703
|
+
const empty = document.createElement("p");
|
|
704
|
+
empty.className = "article-empty";
|
|
705
|
+
empty.textContent = "这一版还没有正文";
|
|
706
|
+
children.push(empty);
|
|
707
|
+
}
|
|
708
|
+
while (this.#nodes.size > BLOCK_POOL_SIZE) {
|
|
709
|
+
const oldest = this.#nodes.keys().next().value;
|
|
710
|
+
if (oldest === undefined)
|
|
711
|
+
break;
|
|
712
|
+
this.#nodes.delete(oldest);
|
|
713
|
+
}
|
|
714
|
+
reconcileChildren(this.#render, children);
|
|
715
|
+
const penNode = pen >= 0 ? children[pen] : undefined;
|
|
716
|
+
if (anchor === "top")
|
|
717
|
+
this.#stage.scrollTop = 0;
|
|
718
|
+
else if (penNode && this.#followPen)
|
|
719
|
+
this.#reveal(penNode);
|
|
720
|
+
else if (pinned)
|
|
721
|
+
this.#stage.scrollTop = this.#stage.scrollHeight;
|
|
722
|
+
if (animate)
|
|
723
|
+
pulseArticleChanges(appeared.slice(0, MAX_PULSED_BLOCKS));
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The article itself. Keys carry only what a block renders, not whether the
|
|
727
|
+
* diff called it new, so a paragraph that was new in one version and settled
|
|
728
|
+
* in the next keeps the node it already had.
|
|
729
|
+
*/
|
|
730
|
+
#readEntries(previous) {
|
|
731
|
+
return blockEntries(diffArticleBlocks(previous, this.#committed)
|
|
732
|
+
.filter((block) => block.kind !== "remove")
|
|
733
|
+
.map((block) => ({ source: block.source, changed: block.kind === "add" })));
|
|
734
|
+
}
|
|
735
|
+
#diffEntries(previous, granularity) {
|
|
736
|
+
const seen = new Map();
|
|
737
|
+
return diffArticleLines(previous, this.#committed, granularity).map((line) => {
|
|
738
|
+
const marks = (line.segments ?? []).map((segment) => `${segment.kind}:${segment.text}`).join("\u0001");
|
|
739
|
+
const signature = `diff\u0000${granularity}\u0000${line.kind}\u0000${line.number}\u0000${line.text}\u0000${marks}`;
|
|
740
|
+
// A revision row carries its own add/remove/same mark; leaving `changed`
|
|
741
|
+
// unset keeps the commit from overwriting it with the block-level one.
|
|
742
|
+
return { key: keyOf(seen, signature), build: () => diffLineNode(line) };
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The draft, drawn through the same keyed blocks as a saved version.
|
|
747
|
+
*
|
|
748
|
+
* A revision is mostly the version before it, and the draft that becomes it is
|
|
749
|
+
* too. Rendering it as its own thing would clear the stage the moment the
|
|
750
|
+
* model starts writing — so by the time the version landed there was nothing
|
|
751
|
+
* left to patch, and the whole document was drawn again.
|
|
752
|
+
*/
|
|
753
|
+
#renderLive(text, baseline, whole) {
|
|
754
|
+
let blocks;
|
|
755
|
+
let pen;
|
|
756
|
+
if (whole) {
|
|
757
|
+
// Edits leave the rest of the document where it stood: this is the next
|
|
758
|
+
// version entire, and the pen is in the last block it changes.
|
|
759
|
+
blocks = diffArticleBlocks(baseline, text)
|
|
760
|
+
.filter((block) => block.kind !== "remove")
|
|
761
|
+
.map((block) => ({ source: block.source, changed: block.kind === "add" }));
|
|
762
|
+
pen = blocks.findLastIndex((block) => block.changed);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
const aligned = alignArticleDraft(baseline, text);
|
|
766
|
+
blocks = [
|
|
767
|
+
...aligned.written,
|
|
768
|
+
// Not reached yet, so it is still what the document says.
|
|
769
|
+
...aligned.pending.map((source) => ({ source, changed: false })),
|
|
770
|
+
];
|
|
771
|
+
pen = aligned.written.length - 1;
|
|
772
|
+
}
|
|
773
|
+
this.#paintSequence += 1;
|
|
774
|
+
this.#commit(blockEntries(blocks), false, "keep", pen);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Scrolls just far enough to keep the block being written on screen, newest
|
|
778
|
+
* line last. The stage is scrolled directly: `scrollIntoView` would also move
|
|
779
|
+
* the clipped panels around it.
|
|
780
|
+
*/
|
|
781
|
+
#reveal(node) {
|
|
782
|
+
const margin = 24;
|
|
783
|
+
const stage = this.#stage.getBoundingClientRect();
|
|
784
|
+
const block = node.getBoundingClientRect();
|
|
785
|
+
if (block.bottom > stage.bottom - margin)
|
|
786
|
+
this.#stage.scrollTop += block.bottom - (stage.bottom - margin);
|
|
787
|
+
else if (block.bottom < stage.top + margin)
|
|
788
|
+
this.#stage.scrollTop -= stage.top + margin - block.top;
|
|
789
|
+
}
|
|
790
|
+
#paintVersions() {
|
|
791
|
+
this.#syncExports();
|
|
792
|
+
this.#versions.replaceChildren();
|
|
793
|
+
for (const version of this.#versionsKnown) {
|
|
794
|
+
const button = document.createElement("button");
|
|
795
|
+
button.type = "button";
|
|
796
|
+
button.className = "article-version";
|
|
797
|
+
button.textContent = `v${version.version}`;
|
|
798
|
+
button.setAttribute("aria-pressed", String(version.version === this.#loadedVersion));
|
|
799
|
+
button.addEventListener("click", () => {
|
|
800
|
+
if (version.version !== this.#loadedVersion)
|
|
801
|
+
void this.open(this.#articleId, version.version);
|
|
802
|
+
});
|
|
803
|
+
this.#versions.append(button);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* One export at a time. The save dialog is modal to the window but the panel
|
|
808
|
+
* is not, and a second click while the first dialog is open would queue
|
|
809
|
+
* another one behind it and write the file twice.
|
|
810
|
+
*/
|
|
811
|
+
async #export(format) {
|
|
812
|
+
if (!this.#articleId || this.#loadedVersion < 1 || this.#exporting)
|
|
813
|
+
return;
|
|
814
|
+
this.#exporting = true;
|
|
815
|
+
this.#syncExports();
|
|
816
|
+
this.#status.textContent = "正在导出…";
|
|
817
|
+
try {
|
|
818
|
+
const result = await this.#exportArticle({ id: this.#articleId, version: this.#loadedVersion, format });
|
|
819
|
+
this.#status.textContent = result.message;
|
|
820
|
+
}
|
|
821
|
+
catch (error) {
|
|
822
|
+
this.#status.textContent = error instanceof Error ? error.message : "导出失败";
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
this.#exporting = false;
|
|
826
|
+
this.#syncExports();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
#syncExports() {
|
|
830
|
+
for (const button of this.#exportButtons)
|
|
831
|
+
button.disabled = this.#exporting || this.#loadedVersion < 1;
|
|
832
|
+
}
|
|
833
|
+
async #refreshReopen(sessionId) {
|
|
834
|
+
const token = this.#token;
|
|
835
|
+
this.#reopenInFlight = true;
|
|
836
|
+
let listed;
|
|
837
|
+
try {
|
|
838
|
+
listed = await this.#dispatch({ action: "article.list", sessionId });
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// Usually "daemon is not connected" during startup.
|
|
842
|
+
this.#reopenInFlight = false;
|
|
843
|
+
if (this.#reopenRetries < REOPEN_RETRY_LIMIT && sessionId === this.#sessionId()) {
|
|
844
|
+
this.#reopenRetries += 1;
|
|
845
|
+
window.setTimeout(() => this.onSessionChanged(), REOPEN_RETRY_MS);
|
|
846
|
+
}
|
|
847
|
+
this.#drainReopenPending();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
this.#reopenInFlight = false;
|
|
851
|
+
this.#reopenRetries = 0;
|
|
852
|
+
if (token !== this.#token || sessionId !== this.#sessionId()) {
|
|
853
|
+
this.#drainReopenPending();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
this.#reopenSession = sessionId;
|
|
857
|
+
const articles = asRecord(listed)["articles"];
|
|
858
|
+
const first = Array.isArray(articles) ? asRecord(articles[0]) : undefined;
|
|
859
|
+
const id = stringValue(first?.["id"]);
|
|
860
|
+
if (!id) {
|
|
861
|
+
this.#reopenId = "";
|
|
862
|
+
this.#reopenTitle = "";
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
this.#reopenId = id;
|
|
866
|
+
this.#reopenTitle = stringValue(first?.["title"]) ?? "文稿";
|
|
867
|
+
}
|
|
868
|
+
this.#syncReopen();
|
|
869
|
+
this.#drainReopenPending();
|
|
870
|
+
}
|
|
871
|
+
#drainReopenPending() {
|
|
872
|
+
if (!this.#reopenPending)
|
|
873
|
+
return;
|
|
874
|
+
this.#reopenPending = false;
|
|
875
|
+
this.onSessionChanged();
|
|
876
|
+
}
|
|
877
|
+
async #load(id, version) {
|
|
878
|
+
const text = await this.#readSlices((offset, limit) => this.#dispatch({
|
|
879
|
+
action: "article.get",
|
|
880
|
+
sessionId: this.#sessionId(),
|
|
881
|
+
id,
|
|
882
|
+
...(version === undefined ? {} : { version }),
|
|
883
|
+
offset,
|
|
884
|
+
limit,
|
|
885
|
+
}));
|
|
886
|
+
if (text === undefined)
|
|
887
|
+
return undefined;
|
|
888
|
+
const head = await this.#dispatch({
|
|
889
|
+
action: "article.get",
|
|
890
|
+
sessionId: this.#sessionId(),
|
|
891
|
+
id,
|
|
892
|
+
...(version === undefined ? {} : { version }),
|
|
893
|
+
offset: 0,
|
|
894
|
+
limit: 1,
|
|
895
|
+
});
|
|
896
|
+
const record = asRecord(head);
|
|
897
|
+
if (typeof record["title"] !== "string" || typeof record["version"] !== "number")
|
|
898
|
+
return undefined;
|
|
899
|
+
const versions = Array.isArray(record["versions"])
|
|
900
|
+
? record["versions"].flatMap((entry) => {
|
|
901
|
+
const versionRecord = asRecord(entry);
|
|
902
|
+
return typeof versionRecord["version"] === "number"
|
|
903
|
+
? [{ version: versionRecord["version"], chars: typeof versionRecord["chars"] === "number" ? versionRecord["chars"] : 0 }]
|
|
904
|
+
: [];
|
|
905
|
+
})
|
|
906
|
+
: [{ version: record["version"], chars: text.length }];
|
|
907
|
+
return { id, title: record["title"], version: record["version"], versions, text };
|
|
908
|
+
}
|
|
909
|
+
async #readSlices(fetchSlice) {
|
|
910
|
+
return readFullMessage(fetchSlice);
|
|
911
|
+
}
|
|
912
|
+
#show(title) {
|
|
913
|
+
const wasOpen = this.#open;
|
|
914
|
+
this.#open = true;
|
|
915
|
+
this.#closing = false;
|
|
916
|
+
this.#panel.hidden = false;
|
|
917
|
+
document.documentElement.dataset["article"] = "open";
|
|
918
|
+
if (title)
|
|
919
|
+
this.#title.textContent = title;
|
|
920
|
+
if (!wasOpen) {
|
|
921
|
+
// Opening over a close still in flight takes the width from wherever that
|
|
922
|
+
// close had got to; the close's own finish is cancelled with it.
|
|
923
|
+
this.#animateWidth(this.#preferredWidth(), () => this.#onLayout());
|
|
924
|
+
this.#onLayout();
|
|
925
|
+
}
|
|
926
|
+
this.#syncReopen();
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Follows the space the split divides: a resized window, or the session rail
|
|
930
|
+
* opening or closing. The reader's share is kept, so both halves scale; an open
|
|
931
|
+
* still animating lands on the new width at once rather than on the stale one.
|
|
932
|
+
*/
|
|
933
|
+
relayout() {
|
|
934
|
+
if (!this.#open)
|
|
935
|
+
return;
|
|
936
|
+
if (this.#widthAnimation) {
|
|
937
|
+
this.#widthAnimation.kill();
|
|
938
|
+
this.#widthAnimation = undefined;
|
|
939
|
+
}
|
|
940
|
+
const width = this.#preferredWidth();
|
|
941
|
+
if (Math.abs(width - this.#shownWidth) < 1)
|
|
942
|
+
return;
|
|
943
|
+
this.#applyWidth(width);
|
|
944
|
+
this.#onLayout();
|
|
945
|
+
}
|
|
946
|
+
#animateWidth(to, finish) {
|
|
947
|
+
this.#widthAnimation?.kill();
|
|
948
|
+
this.#widthAnimation = undefined;
|
|
949
|
+
// Without the engine, or with reduced motion, the animation finishes before
|
|
950
|
+
// it returns; only one that is still running is kept as the one in flight.
|
|
951
|
+
const run = { finished: false };
|
|
952
|
+
const animation = animateArticleWidth(this.#shownWidth, to, (width) => this.#applyWidth(width), () => {
|
|
953
|
+
run.finished = true;
|
|
954
|
+
if (this.#widthAnimation === run.animation)
|
|
955
|
+
this.#widthAnimation = undefined;
|
|
956
|
+
finish();
|
|
957
|
+
});
|
|
958
|
+
if (!run.finished) {
|
|
959
|
+
run.animation = animation;
|
|
960
|
+
this.#widthAnimation = animation;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* The width the two columns divide between them.
|
|
965
|
+
*
|
|
966
|
+
* Measured rather than derived from the viewport: the session rail, the gaps
|
|
967
|
+
* and the page padding all come out of it, and the rail can be open, closed or
|
|
968
|
+
* a drawer that takes no column at all. The conversation's left edge does not
|
|
969
|
+
* move with the split, so this is stable while the boundary is dragged.
|
|
970
|
+
*/
|
|
971
|
+
#availableWidth() {
|
|
972
|
+
const grid = this.#panel.parentElement;
|
|
973
|
+
if (!grid)
|
|
974
|
+
return window.innerWidth;
|
|
975
|
+
const gap = Number.parseFloat(getComputedStyle(grid).columnGap) || 0;
|
|
976
|
+
return Math.max(0, grid.getBoundingClientRect().right - this.#conversation.getBoundingClientRect().left - gap);
|
|
977
|
+
}
|
|
978
|
+
/** The reader's share of the space, applied to the space there is now. */
|
|
979
|
+
#preferredWidth() {
|
|
980
|
+
return articleWidthForShare(this.#storedShare, this.#availableWidth());
|
|
981
|
+
}
|
|
982
|
+
#applyWidth(width) {
|
|
983
|
+
this.#shownWidth = width;
|
|
984
|
+
document.documentElement.style.setProperty("--article-width", `${Math.max(0, Math.round(width))}px`);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* The boundary between the conversation and the document.
|
|
988
|
+
*
|
|
989
|
+
* Dragging writes the width straight through — an animated drag would lag the
|
|
990
|
+
* pointer — and the chosen share is remembered for the next time the panel
|
|
991
|
+
* opens. The drag is measured from where it started, so grabbing the handle
|
|
992
|
+
* does not make the column jump to the pointer.
|
|
993
|
+
*/
|
|
994
|
+
#bindResize() {
|
|
995
|
+
const handle = this.#resize;
|
|
996
|
+
let dragStartX = 0;
|
|
997
|
+
let dragStartWidth = 0;
|
|
998
|
+
const commit = (width) => {
|
|
999
|
+
// A close in flight owns the width until the column is gone.
|
|
1000
|
+
if (!this.#open)
|
|
1001
|
+
return;
|
|
1002
|
+
if (this.#widthAnimation) {
|
|
1003
|
+
this.#widthAnimation.kill();
|
|
1004
|
+
this.#widthAnimation = undefined;
|
|
1005
|
+
}
|
|
1006
|
+
const available = this.#availableWidth();
|
|
1007
|
+
const next = clampArticleWidth(width, available);
|
|
1008
|
+
this.#storedShare = articleShareForWidth(next, available) ?? this.#storedShare;
|
|
1009
|
+
this.#applyWidth(next);
|
|
1010
|
+
this.#onLayout();
|
|
1011
|
+
try {
|
|
1012
|
+
if (this.#storedShare !== undefined)
|
|
1013
|
+
window.localStorage.setItem(ARTICLE_SHARE_KEY, this.#storedShare.toFixed(4));
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
// Private windows and blocked site data: the split still applies for this run.
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
1020
|
+
if (event.button !== 0 || !this.#open)
|
|
1021
|
+
return;
|
|
1022
|
+
event.preventDefault();
|
|
1023
|
+
handle.setPointerCapture(event.pointerId);
|
|
1024
|
+
dragStartX = event.clientX;
|
|
1025
|
+
dragStartWidth = this.#shownWidth;
|
|
1026
|
+
handle.dataset["dragging"] = "true";
|
|
1027
|
+
document.documentElement.dataset["articleResizing"] = "true";
|
|
1028
|
+
});
|
|
1029
|
+
handle.addEventListener("pointermove", (event) => {
|
|
1030
|
+
if (handle.dataset["dragging"] !== "true")
|
|
1031
|
+
return;
|
|
1032
|
+
// The panel is flush with the workspace's right edge, so moving the
|
|
1033
|
+
// boundary left by a pixel widens the document by one.
|
|
1034
|
+
commit(dragStartWidth + dragStartX - event.clientX);
|
|
1035
|
+
});
|
|
1036
|
+
const release = (event) => {
|
|
1037
|
+
if (handle.dataset["dragging"] !== "true")
|
|
1038
|
+
return;
|
|
1039
|
+
delete handle.dataset["dragging"];
|
|
1040
|
+
delete document.documentElement.dataset["articleResizing"];
|
|
1041
|
+
if (handle.hasPointerCapture(event.pointerId))
|
|
1042
|
+
handle.releasePointerCapture(event.pointerId);
|
|
1043
|
+
};
|
|
1044
|
+
handle.addEventListener("pointerup", release);
|
|
1045
|
+
handle.addEventListener("pointercancel", release);
|
|
1046
|
+
handle.addEventListener("lostpointercapture", release);
|
|
1047
|
+
handle.addEventListener("keydown", (event) => {
|
|
1048
|
+
if (!this.#open)
|
|
1049
|
+
return;
|
|
1050
|
+
const step = event.key === "ArrowLeft" ? ARTICLE_WIDTH_STEP : event.key === "ArrowRight" ? -ARTICLE_WIDTH_STEP : 0;
|
|
1051
|
+
if (step === 0)
|
|
1052
|
+
return;
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
commit(this.#shownWidth + step);
|
|
1055
|
+
});
|
|
1056
|
+
window.addEventListener("resize", () => this.relayout());
|
|
1057
|
+
// The session rail is the other thing that changes the space: toggled from
|
|
1058
|
+
// the top bar, or by the renderer as the window crosses a breakpoint.
|
|
1059
|
+
new MutationObserver(() => this.relayout()).observe(document.documentElement, {
|
|
1060
|
+
attributes: true,
|
|
1061
|
+
attributeFilter: ["data-navigation"],
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* The first version has nothing to compare against, so the revision views stay
|
|
1066
|
+
* out of reach until there is a baseline to compare with.
|
|
1067
|
+
*/
|
|
1068
|
+
#syncViews(comparable) {
|
|
1069
|
+
if (!comparable && this.#view !== "read")
|
|
1070
|
+
this.#view = "read";
|
|
1071
|
+
this.#panel.dataset["view"] = this.#view;
|
|
1072
|
+
for (const button of this.#viewButtons) {
|
|
1073
|
+
const view = button.dataset["view"];
|
|
1074
|
+
button.disabled = !comparable && view !== "read";
|
|
1075
|
+
button.setAttribute("aria-pressed", String(view === this.#view));
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
#syncReopen() {
|
|
1079
|
+
const visible = !this.#open && this.#reopenId !== "";
|
|
1080
|
+
this.#reopen.hidden = !visible;
|
|
1081
|
+
this.#reopen.textContent = this.#reopenTitle || "文稿";
|
|
1082
|
+
}
|
|
1083
|
+
#pinnedToEnd() {
|
|
1084
|
+
return this.#stage.scrollHeight - this.#stage.scrollTop - this.#stage.clientHeight < 48;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function readStoredShare() {
|
|
1088
|
+
try {
|
|
1089
|
+
const raw = window.localStorage.getItem(ARTICLE_SHARE_KEY);
|
|
1090
|
+
if (raw === null)
|
|
1091
|
+
return undefined;
|
|
1092
|
+
const stored = Number(raw);
|
|
1093
|
+
return Number.isFinite(stored) && stored > 0 && stored < 1 ? stored : undefined;
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
return undefined;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function requiredElement(id) {
|
|
1100
|
+
const element = document.getElementById(id);
|
|
1101
|
+
if (!element)
|
|
1102
|
+
throw new Error(`Missing #${id}`);
|
|
1103
|
+
return element;
|
|
1104
|
+
}
|
|
1105
|
+
function requiredButton(id) {
|
|
1106
|
+
const element = requiredElement(id);
|
|
1107
|
+
if (!(element instanceof HTMLButtonElement))
|
|
1108
|
+
throw new Error(`#${id} is not a button`);
|
|
1109
|
+
return element;
|
|
1110
|
+
}
|
|
1111
|
+
function toolResultText(result) {
|
|
1112
|
+
const content = asRecord(result)["content"];
|
|
1113
|
+
if (!Array.isArray(content))
|
|
1114
|
+
return "";
|
|
1115
|
+
return content.map((block) => {
|
|
1116
|
+
const text = asRecord(block)["text"];
|
|
1117
|
+
return typeof text === "string" ? text : "";
|
|
1118
|
+
}).filter(Boolean).join("\n");
|
|
1119
|
+
}
|
|
1120
|
+
function asRecord(value) {
|
|
1121
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1122
|
+
}
|
|
1123
|
+
function stringValue(value) {
|
|
1124
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
1125
|
+
}
|
|
1126
|
+
//# sourceMappingURL=article-panel.js.map
|