p-spore 0.1.4 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -15
- 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 +93 -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 +1788 -151
- 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 +910 -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
|
@@ -1,22 +1,106 @@
|
|
|
1
1
|
/// <reference lib="DOM" />
|
|
2
|
+
import { publishedTextTruncation } from "../../protocol/limits.js";
|
|
2
3
|
import { defaultModelFor } from "../../settings/llm-settings.js";
|
|
3
|
-
import { clockTime, conversationIntent, formatBytes, formatTokens, presentSessions, selectedAgent as findSelectedAgent, shortId, terminalAgentStatus, } from "../../ui/shared/presentation.js";
|
|
4
|
-
import {
|
|
4
|
+
import { clockTime, conversationIntent, formatBytes, formatTokens, kernelMetrics, listTime, presentSessions, selectedAgent as findSelectedAgent, shortId, terminalAgentStatus, } from "../../ui/shared/presentation.js";
|
|
5
|
+
import { readFullMessage } from "../../ui/shared/full-message.js";
|
|
6
|
+
import { isScrollNearEnd, VirtualHeightIndex } from "../../ui/shared/virtual-list.js";
|
|
7
|
+
import { DAEMON_NOT_CONNECTED_MESSAGE, } from "../ipc-contract.js";
|
|
8
|
+
import { composeMessageWithFileReferences, composerFileReference, composerFileReferenceName, mergeComposerFileReferences, } from "./composer-file-references.js";
|
|
9
|
+
import { parseArticleReceipt } from "../../articles/receipt.js";
|
|
10
|
+
import { ArticlePanel } from "./article-panel.js";
|
|
11
|
+
import { formatElapsed, presentIndexing } from "./indexing-view.js";
|
|
12
|
+
import { reconcileChildren } from "./dom-reconcile.js";
|
|
13
|
+
import { conversationRows, conversationRowText, isToolMessage, toolCallSummary } from "./conversation.js";
|
|
14
|
+
import { appendMarkdown, createMarkdownSurface } from "./markdown-view.js";
|
|
15
|
+
import { createSessionSearch } from "./session-search.js";
|
|
16
|
+
import { animateMessagesIn, animateTranscriptHero, crossfadeText, flipStatusChip, hideOverlayMotion, introChoreography, nudgeElement, popChips, pulseStatus, revealListItems, revealPanel, setReconnectingPulse, showOverlayMotion, toggleBannerMotion, tweenMetric, } from "./motion.js";
|
|
17
|
+
import { CONNECTION_SETTLE_MS, DesktopStore } from "./store.js";
|
|
5
18
|
const store = new DesktopStore();
|
|
6
19
|
const queuedFrames = [];
|
|
7
20
|
let initialized = false;
|
|
8
21
|
let versions;
|
|
9
22
|
let jobs = [];
|
|
10
23
|
let resources = [];
|
|
24
|
+
/** The fixed tools this build ships, as reported by the daemon. */
|
|
25
|
+
let builtinTools = [];
|
|
26
|
+
/** Every skill new sessions are offered, and the ones found but not loaded. */
|
|
27
|
+
let skillCatalog = [];
|
|
28
|
+
const SKILL_SOURCE_LABELS = {
|
|
29
|
+
installed: "已安装",
|
|
30
|
+
bundled: "内置",
|
|
31
|
+
workspace: "本地",
|
|
32
|
+
};
|
|
11
33
|
let inspection;
|
|
12
34
|
let activeApprovalId;
|
|
13
35
|
let renderSequence = 0;
|
|
14
36
|
let settingsOpen = false;
|
|
15
37
|
let savingSettings = false;
|
|
16
38
|
let responsiveMode;
|
|
39
|
+
let introPlayed = false;
|
|
40
|
+
let lastConnectionPhase = "";
|
|
41
|
+
let settleTimer;
|
|
42
|
+
let lastSessionSignature = "";
|
|
43
|
+
let lastSessionStatus = "";
|
|
44
|
+
let lastActivitySignature = "";
|
|
45
|
+
let lastReferenceCount = 0;
|
|
46
|
+
let indexingDirectories = [];
|
|
47
|
+
/** Session whose delete control is armed, so a stray click cannot destroy history. */
|
|
48
|
+
let armedSessionDeletion;
|
|
49
|
+
let armSessionDeletionTimer;
|
|
50
|
+
let renderFrame;
|
|
51
|
+
let transcriptRenderFrame;
|
|
52
|
+
let composerFileReferences = [];
|
|
53
|
+
/** Well-known directories, filled in by the Electron main process at bootstrap. */
|
|
54
|
+
let desktopPlaces;
|
|
55
|
+
/** How long a delete control stays armed before it reverts on its own. */
|
|
56
|
+
const SESSION_DELETE_ARM_MS = 5_000;
|
|
57
|
+
/**
|
|
58
|
+
* The run phase currently on screen and when it began.
|
|
59
|
+
*
|
|
60
|
+
* Elapsed time is measured here rather than taken from the daemon because what
|
|
61
|
+
* the reader is asking is "how long has this been showing me nothing", which is
|
|
62
|
+
* a question about their own screen.
|
|
63
|
+
*/
|
|
64
|
+
let activityPhase;
|
|
65
|
+
let activityTicker;
|
|
66
|
+
let indexingTicker;
|
|
67
|
+
const transcriptState = {
|
|
68
|
+
sessionId: "",
|
|
69
|
+
end: -1,
|
|
70
|
+
loadingHistory: false,
|
|
71
|
+
heights: new VirtualHeightIndex(180),
|
|
72
|
+
nodes: new Map(),
|
|
73
|
+
measureFrame: undefined,
|
|
74
|
+
messageSource: undefined,
|
|
75
|
+
items: [],
|
|
76
|
+
running: false,
|
|
77
|
+
hasStreaming: false,
|
|
78
|
+
followTail: true,
|
|
79
|
+
pinnedIndex: undefined,
|
|
80
|
+
spacerStart: virtualSpacer("start"),
|
|
81
|
+
spacerEnd: virtualSpacer("end"),
|
|
82
|
+
stream: undefined,
|
|
83
|
+
};
|
|
17
84
|
const THEME_STORAGE_KEY = "pspore.desktop.theme";
|
|
85
|
+
/** Distance from the top that triggers loading the page before the tail. */
|
|
86
|
+
const HISTORY_BACKFILL_TRIGGER_PX = 240;
|
|
87
|
+
const HISTORY_PAGE_SIZE = 200;
|
|
88
|
+
/** How many restored full messages are kept, so scrollback cannot grow forever. */
|
|
89
|
+
const FULL_TEXT_CACHE_ENTRIES = 4;
|
|
90
|
+
/**
|
|
91
|
+
* Full text for messages the daemon had to cut, keyed by session and message.
|
|
92
|
+
* Bounded, and dropped oldest-first: restoring a few very long messages must not
|
|
93
|
+
* turn the transcript into an unbounded buffer.
|
|
94
|
+
*/
|
|
95
|
+
const fullMessageTexts = new Map();
|
|
96
|
+
const loadingFullText = new Set();
|
|
97
|
+
const processViews = new Map();
|
|
98
|
+
/** Kept separately from the DOM cache so scrolling does not reset disclosure. */
|
|
99
|
+
const disclosureStates = new Map();
|
|
18
100
|
const elements = {
|
|
19
101
|
appShell: byId("app-shell"),
|
|
102
|
+
topbar: document.querySelector(".topbar") ?? missingElement(".topbar"),
|
|
103
|
+
conversationPanel: document.querySelector(".conversation") ?? missingElement(".conversation"),
|
|
20
104
|
navigationPanel: byId("navigation-panel"),
|
|
21
105
|
navigationToggle: byId("navigation-toggle"),
|
|
22
106
|
inspectorPanel: byId("inspector-panel"),
|
|
@@ -28,6 +112,7 @@ const elements = {
|
|
|
28
112
|
daemonInstance: byId("daemon-instance"),
|
|
29
113
|
revision: byId("revision-value"),
|
|
30
114
|
usage: byId("usage-value"),
|
|
115
|
+
totalUsage: byId("total-usage-value"),
|
|
31
116
|
cache: byId("cache-value"),
|
|
32
117
|
themeToggle: byId("theme-toggle"),
|
|
33
118
|
themeColor: byId("theme-color"),
|
|
@@ -35,7 +120,22 @@ const elements = {
|
|
|
35
120
|
connectionBanner: byId("connection-banner"),
|
|
36
121
|
connectionMessage: byId("connection-message"),
|
|
37
122
|
sessionCount: byId("session-count"),
|
|
123
|
+
newSession: byId("new-session-button"),
|
|
38
124
|
sessionList: byId("session-list"),
|
|
125
|
+
runActivity: byId("run-activity"),
|
|
126
|
+
runActivityMark: byId("run-activity-mark"),
|
|
127
|
+
runActivityLabel: byId("run-activity-label"),
|
|
128
|
+
runActivityElapsed: byId("run-activity-elapsed"),
|
|
129
|
+
indexingPanel: byId("indexing-panel"),
|
|
130
|
+
indexingPhase: byId("indexing-phase"),
|
|
131
|
+
indexingBarFill: byId("indexing-bar-fill"),
|
|
132
|
+
indexingCounts: byId("indexing-counts"),
|
|
133
|
+
indexingCurrent: byId("indexing-current"),
|
|
134
|
+
indexingNote: byId("indexing-note"),
|
|
135
|
+
railRuns: byId("rail-runs"),
|
|
136
|
+
railMcp: byId("rail-mcp"),
|
|
137
|
+
railResources: byId("rail-resources"),
|
|
138
|
+
railApprovals: byId("rail-approvals"),
|
|
39
139
|
agentCompose: byId("agent-compose"),
|
|
40
140
|
agentComposeToggle: byId("agent-compose-toggle"),
|
|
41
141
|
agentComposeClose: byId("agent-compose-close"),
|
|
@@ -51,13 +151,22 @@ const elements = {
|
|
|
51
151
|
versionLine: byId("version-line"),
|
|
52
152
|
runtimeLine: byId("runtime-line"),
|
|
53
153
|
sessionTitle: byId("session-title"),
|
|
154
|
+
sessionSearchButton: byId("session-search-button"),
|
|
155
|
+
sessionSearch: byId("session-search"),
|
|
156
|
+
sessionSearchInput: byId("session-search-input"),
|
|
157
|
+
sessionSearchCount: byId("session-search-count"),
|
|
158
|
+
sessionSearchPrevious: byId("session-search-prev"),
|
|
159
|
+
sessionSearchNext: byId("session-search-next"),
|
|
160
|
+
sessionSearchClose: byId("session-search-close"),
|
|
54
161
|
sessionStatus: byId("session-status"),
|
|
55
162
|
modelName: byId("model-name"),
|
|
56
163
|
transcript: byId("transcript"),
|
|
57
164
|
notice: byId("notice-line"),
|
|
165
|
+
messageQueue: byId("message-queue"),
|
|
58
166
|
composerForm: byId("composer-form"),
|
|
59
167
|
composerInput: byId("composer-input"),
|
|
60
|
-
|
|
168
|
+
composerDropOverlay: byId("composer-drop-overlay"),
|
|
169
|
+
composerReferences: byId("composer-references"),
|
|
61
170
|
compact: byId("compact-button"),
|
|
62
171
|
abort: byId("abort-button"),
|
|
63
172
|
send: byId("send-button"),
|
|
@@ -79,12 +188,41 @@ const elements = {
|
|
|
79
188
|
settingsBaseUrl: byId("settings-base-url"),
|
|
80
189
|
settingsApi: byId("settings-api"),
|
|
81
190
|
settingsThinking: byId("settings-thinking"),
|
|
191
|
+
settingsPermissionMode: byId("settings-permission-mode"),
|
|
192
|
+
settingsPermissionHelp: byId("settings-permission-help"),
|
|
193
|
+
settingsPermissionWarning: byId("settings-permission-warning"),
|
|
194
|
+
settingsAutoCompaction: byId("settings-auto-compaction"),
|
|
195
|
+
settingsCompactionTrigger: byId("settings-compaction-trigger"),
|
|
196
|
+
settingsCompactionReserve: byId("settings-compaction-reserve"),
|
|
197
|
+
settingsCompactionKeepRecent: byId("settings-compaction-keep-recent"),
|
|
82
198
|
settingsApiKey: byId("settings-api-key"),
|
|
83
199
|
settingsKeyReveal: byId("settings-key-reveal"),
|
|
84
200
|
settingsClearKey: byId("settings-clear-key"),
|
|
201
|
+
settingsCookie: byId("settings-cookie"),
|
|
202
|
+
settingsCookieReveal: byId("settings-cookie-reveal"),
|
|
203
|
+
settingsClearCookie: byId("settings-clear-cookie"),
|
|
204
|
+
settingsConfigPath: byId("settings-config-path"),
|
|
205
|
+
settingsModelsPath: byId("settings-models-path"),
|
|
206
|
+
settingsApiKeyPath: byId("settings-api-key-path"),
|
|
207
|
+
settingsApiKeyState: byId("settings-api-key-state"),
|
|
208
|
+
settingsKeyCopyPath: byId("settings-key-copy-path"),
|
|
209
|
+
settingsCookiePath: byId("settings-cookie-path"),
|
|
210
|
+
attachFile: byId("attach-file-button"),
|
|
211
|
+
attachDirectory: byId("attach-directory-button"),
|
|
212
|
+
settingsHeaderList: byId("settings-header-list"),
|
|
213
|
+
settingsHeaderAdd: byId("settings-header-add"),
|
|
214
|
+
settingsCookieState: byId("settings-cookie-state"),
|
|
215
|
+
settingsCookieCopyPath: byId("settings-cookie-copy-path"),
|
|
216
|
+
settingsIndexingEnabled: byId("settings-indexing-enabled"),
|
|
217
|
+
settingsIndexingInterval: byId("settings-indexing-interval"),
|
|
218
|
+
settingsIndexingExt: byId("settings-indexing-ext"),
|
|
219
|
+
settingsIndexingAdd: byId("settings-indexing-add"),
|
|
220
|
+
settingsIndexingList: byId("settings-indexing-list"),
|
|
221
|
+
settingsIndexingNote: byId("settings-indexing-note"),
|
|
85
222
|
settingsStatus: byId("settings-status"),
|
|
86
223
|
settingsSave: byId("settings-save"),
|
|
87
224
|
approvalOverlay: byId("approval-overlay"),
|
|
225
|
+
approvalDialog: document.querySelector(".approval-dialog") ?? missingElement(".approval-dialog"),
|
|
88
226
|
approvalTitle: byId("approval-title"),
|
|
89
227
|
approvalSummary: byId("approval-summary"),
|
|
90
228
|
approvalCapability: byId("approval-capability"),
|
|
@@ -93,14 +231,53 @@ const elements = {
|
|
|
93
231
|
approvalCommand: byId("approval-command"),
|
|
94
232
|
approvalActions: byId("approval-actions"),
|
|
95
233
|
};
|
|
234
|
+
const sessionSearch = createSessionSearch({
|
|
235
|
+
bar: elements.sessionSearch,
|
|
236
|
+
input: elements.sessionSearchInput,
|
|
237
|
+
count: elements.sessionSearchCount,
|
|
238
|
+
button: elements.sessionSearchButton,
|
|
239
|
+
previous: elements.sessionSearchPrevious,
|
|
240
|
+
next: elements.sessionSearchNext,
|
|
241
|
+
close: elements.sessionSearchClose,
|
|
242
|
+
transcript: elements.transcript,
|
|
243
|
+
requestRender: () => scheduleTranscriptRender(),
|
|
244
|
+
getTexts: () => currentSessionTexts(store.snapshot()),
|
|
245
|
+
getSessionId: () => store.snapshot().selectedSessionId,
|
|
246
|
+
getOffset: (index) => transcriptState.heights.offsetAt(index),
|
|
247
|
+
setFollowTail: (value) => {
|
|
248
|
+
transcriptState.followTail = value;
|
|
249
|
+
},
|
|
250
|
+
setPinnedIndex: (index) => {
|
|
251
|
+
transcriptState.pinnedIndex = index;
|
|
252
|
+
},
|
|
253
|
+
forEachMessage: (visit) => {
|
|
254
|
+
for (const node of transcriptState.nodes.values())
|
|
255
|
+
visit(node);
|
|
256
|
+
if (transcriptState.stream)
|
|
257
|
+
visit(transcriptState.stream.node);
|
|
258
|
+
},
|
|
259
|
+
isModalOpen: () => !elements.settingsOverlay.hidden || !elements.approvalOverlay.hidden,
|
|
260
|
+
});
|
|
96
261
|
applyTheme(initialTheme());
|
|
97
262
|
syncResponsivePanels(true);
|
|
263
|
+
const articles = new ArticlePanel({
|
|
264
|
+
sessionId: () => store.snapshot().selectedSessionId,
|
|
265
|
+
dispatch: (command) => window.pspore.dispatch(command),
|
|
266
|
+
exportArticle: (request) => window.pspore.exportArticle(request),
|
|
267
|
+
onLayout: () => scheduleResizeReflow(),
|
|
268
|
+
});
|
|
98
269
|
const unsubscribe = window.pspore.subscribe((frame) => {
|
|
270
|
+
if (frame.kind === "event")
|
|
271
|
+
articles.onEvent(frame.event);
|
|
99
272
|
if (!initialized)
|
|
100
273
|
queuedFrames.push(frame);
|
|
101
274
|
else {
|
|
102
275
|
store.applyFrame(frame);
|
|
103
|
-
|
|
276
|
+
// The selection can change without anyone clicking: a restored session
|
|
277
|
+
// arrives with the first snapshot, and a reconnect replaces it. The panel
|
|
278
|
+
// no-ops when nothing changed.
|
|
279
|
+
articles.onSessionChanged();
|
|
280
|
+
scheduleRender();
|
|
104
281
|
}
|
|
105
282
|
});
|
|
106
283
|
window.addEventListener("beforeunload", unsubscribe, { once: true });
|
|
@@ -110,27 +287,42 @@ async function initialize() {
|
|
|
110
287
|
try {
|
|
111
288
|
const bootstrap = await window.pspore.bootstrap();
|
|
112
289
|
versions = bootstrap.versions;
|
|
290
|
+
desktopPlaces = bootstrap.places;
|
|
113
291
|
store.applyBootstrap(bootstrap);
|
|
292
|
+
articles.onSessionChanged();
|
|
114
293
|
initialized = true;
|
|
115
294
|
for (const frame of queuedFrames.splice(0))
|
|
116
295
|
store.applyFrame(frame);
|
|
296
|
+
articles.onSessionChanged();
|
|
117
297
|
render();
|
|
118
298
|
await refreshSideData();
|
|
119
299
|
}
|
|
120
300
|
catch (error) {
|
|
121
301
|
initialized = true;
|
|
122
|
-
store.setNotice(
|
|
302
|
+
store.setNotice(`桌面初始化失败:${toError(error).message}`);
|
|
123
303
|
render();
|
|
124
304
|
}
|
|
125
305
|
finally {
|
|
126
306
|
document.documentElement.dataset["ready"] = "true";
|
|
307
|
+
playIntroOnce();
|
|
127
308
|
}
|
|
128
309
|
}
|
|
310
|
+
function playIntroOnce() {
|
|
311
|
+
if (introPlayed)
|
|
312
|
+
return;
|
|
313
|
+
introPlayed = true;
|
|
314
|
+
introChoreography(elements.topbar, [elements.navigationPanel, elements.conversationPanel, elements.inspectorPanel]);
|
|
315
|
+
}
|
|
129
316
|
function bindStaticActions() {
|
|
130
317
|
elements.navigationToggle.addEventListener("click", () => {
|
|
131
318
|
setPanelState("navigation", document.documentElement.dataset["navigation"] !== "open");
|
|
132
319
|
});
|
|
133
320
|
elements.inspectorToggle.addEventListener("click", () => {
|
|
321
|
+
if (articles.isOpen()) {
|
|
322
|
+
articles.close();
|
|
323
|
+
setPanelState("inspector", true);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
134
326
|
setPanelState("inspector", document.documentElement.dataset["inspector"] !== "open");
|
|
135
327
|
});
|
|
136
328
|
elements.inspectorClose.addEventListener("click", () => setPanelState("inspector", false));
|
|
@@ -140,10 +332,15 @@ function bindStaticActions() {
|
|
|
140
332
|
elements.agentComposeToggle.addEventListener("click", () => setAgentCompose(elements.agentCompose.hidden));
|
|
141
333
|
elements.agentComposeClose.addEventListener("click", () => setAgentCompose(false));
|
|
142
334
|
elements.resourceShortcut.addEventListener("click", () => {
|
|
335
|
+
if (articles.isOpen())
|
|
336
|
+
articles.close();
|
|
143
337
|
selectInspectorTab("resources");
|
|
144
338
|
setPanelState("inspector", true);
|
|
145
339
|
});
|
|
146
|
-
window.addEventListener("resize", () =>
|
|
340
|
+
window.addEventListener("resize", () => {
|
|
341
|
+
syncResponsivePanels(false);
|
|
342
|
+
scheduleResizeReflow();
|
|
343
|
+
});
|
|
147
344
|
elements.themeToggle.addEventListener("click", () => {
|
|
148
345
|
const theme = document.documentElement.dataset["theme"] === "light" ? "dark" : "light";
|
|
149
346
|
applyTheme(theme);
|
|
@@ -155,6 +352,15 @@ function bindStaticActions() {
|
|
|
155
352
|
}
|
|
156
353
|
});
|
|
157
354
|
elements.refresh.addEventListener("click", () => void refreshSideData());
|
|
355
|
+
elements.newSession.addEventListener("click", () => {
|
|
356
|
+
void perform("新建会话", { action: "session.create" }, (result) => {
|
|
357
|
+
if (!isSessionView(result))
|
|
358
|
+
throw new Error("服务返回了无效的会话");
|
|
359
|
+
store.selectSession(result.sessionId);
|
|
360
|
+
articles.onSessionChanged();
|
|
361
|
+
elements.composerInput.focus();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
158
364
|
elements.settingsButton.addEventListener("click", () => void openSettings());
|
|
159
365
|
elements.settingsClose.addEventListener("click", () => closeSettings());
|
|
160
366
|
elements.settingsCancel.addEventListener("click", () => closeSettings());
|
|
@@ -163,19 +369,15 @@ function bindStaticActions() {
|
|
|
163
369
|
void saveSettings();
|
|
164
370
|
});
|
|
165
371
|
elements.settingsProvider.addEventListener("change", () => configureProviderFields(true));
|
|
166
|
-
elements.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
elements.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
elements.settingsClearKey.addEventListener("change", () => {
|
|
176
|
-
if (elements.settingsClearKey.checked)
|
|
177
|
-
elements.settingsApiKey.value = "";
|
|
178
|
-
});
|
|
372
|
+
elements.settingsPermissionMode.addEventListener("change", () => configurePermissionFields());
|
|
373
|
+
bindSecretField(elements.settingsApiKey, elements.settingsKeyReveal, elements.settingsClearKey);
|
|
374
|
+
bindSecretField(elements.settingsCookie, elements.settingsCookieReveal, elements.settingsClearCookie);
|
|
375
|
+
bindPathCopy(elements.settingsKeyCopyPath, elements.settingsApiKeyPath);
|
|
376
|
+
elements.settingsIndexingAdd.addEventListener("click", () => void addIndexingDirectory());
|
|
377
|
+
bindPathCopy(elements.settingsCookieCopyPath, elements.settingsCookiePath);
|
|
378
|
+
elements.settingsHeaderAdd.addEventListener("click", () => addCustomHeaderRow("", false));
|
|
379
|
+
elements.attachFile.addEventListener("click", () => void chooseAttachments("file"));
|
|
380
|
+
elements.attachDirectory.addEventListener("click", () => void chooseAttachments("directory"));
|
|
179
381
|
elements.settingsOverlay.addEventListener("keydown", (event) => {
|
|
180
382
|
if (event.key === "Escape" && !savingSettings) {
|
|
181
383
|
event.preventDefault();
|
|
@@ -189,17 +391,24 @@ function bindStaticActions() {
|
|
|
189
391
|
void submitComposer();
|
|
190
392
|
});
|
|
191
393
|
elements.composerInput.addEventListener("keydown", (event) => {
|
|
192
|
-
if (event.key
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
394
|
+
if (event.key !== "Enter" || event.isComposing)
|
|
395
|
+
return;
|
|
396
|
+
// Enter sends; Ctrl/Cmd+Enter, Shift+Enter and Alt+Enter insert a newline.
|
|
397
|
+
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)
|
|
398
|
+
return;
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
elements.composerForm.requestSubmit();
|
|
196
401
|
});
|
|
402
|
+
bindComposerFileDrop();
|
|
403
|
+
elements.transcript.addEventListener("scroll", handleTranscriptScroll, { passive: true });
|
|
197
404
|
elements.spawnForm.addEventListener("submit", (event) => {
|
|
198
405
|
event.preventDefault();
|
|
199
406
|
const objective = elements.spawnObjective.value.trim();
|
|
200
407
|
if (!objective)
|
|
201
408
|
return;
|
|
202
|
-
|
|
409
|
+
const snapshot = store.snapshot();
|
|
410
|
+
const parentId = selectedAgent(snapshot)?.rootId ?? snapshot.selectedSessionId;
|
|
411
|
+
void perform("启动子 Agent", { action: "agent.spawn", objective, parentId }, () => {
|
|
203
412
|
elements.spawnObjective.value = "";
|
|
204
413
|
setAgentCompose(false);
|
|
205
414
|
return refreshSideData();
|
|
@@ -208,26 +417,31 @@ function bindStaticActions() {
|
|
|
208
417
|
elements.abort.addEventListener("click", () => {
|
|
209
418
|
const snapshot = store.snapshot();
|
|
210
419
|
const child = selectedAgent(snapshot);
|
|
211
|
-
const command = child
|
|
212
|
-
|
|
420
|
+
const command = child
|
|
421
|
+
? { action: "agent.cancel", agentId: child.id }
|
|
422
|
+
: { action: "abort", sessionId: snapshot.selectedSessionId };
|
|
423
|
+
void perform(child ? `取消 ${child.id}` : "中止主会话", command);
|
|
424
|
+
});
|
|
425
|
+
elements.compact.addEventListener("click", () => {
|
|
426
|
+
const sessionId = store.snapshot().selectedSessionId;
|
|
427
|
+
void perform("压缩会话上下文", { action: "compact", sessionId });
|
|
213
428
|
});
|
|
214
|
-
elements.compact.addEventListener("click", () => void perform("Compacting main context", { action: "compact" }));
|
|
215
429
|
elements.joinAgent.addEventListener("click", () => {
|
|
216
430
|
const child = selectedAgent(store.snapshot());
|
|
217
431
|
if (child)
|
|
218
|
-
void perform(
|
|
432
|
+
void perform(`等待 ${child.id} 完成`, { action: "agent.join", agentIds: [child.id] });
|
|
219
433
|
});
|
|
220
434
|
elements.cancelAgent.addEventListener("click", () => {
|
|
221
435
|
const child = selectedAgent(store.snapshot());
|
|
222
436
|
if (child)
|
|
223
|
-
void perform(
|
|
437
|
+
void perform(`取消 ${child.id}`, { action: "agent.cancel", agentId: child.id });
|
|
224
438
|
});
|
|
225
439
|
elements.inspectForm.addEventListener("submit", (event) => {
|
|
226
440
|
event.preventDefault();
|
|
227
441
|
const stagingName = elements.stagingName.value.trim();
|
|
228
442
|
if (!stagingName)
|
|
229
443
|
return;
|
|
230
|
-
void perform("
|
|
444
|
+
void perform("检查暂存资源", { action: "resources.inspect", stagingName }, (result) => {
|
|
231
445
|
inspection = isInspection(result) ? result : undefined;
|
|
232
446
|
renderInspection();
|
|
233
447
|
});
|
|
@@ -240,13 +454,191 @@ function bindStaticActions() {
|
|
|
240
454
|
trapDialogFocus(event, elements.approvalOverlay);
|
|
241
455
|
});
|
|
242
456
|
}
|
|
457
|
+
function bindComposerFileDrop() {
|
|
458
|
+
elements.composerForm.addEventListener("dragenter", (event) => {
|
|
459
|
+
if (!hasDraggedFiles(event.dataTransfer))
|
|
460
|
+
return;
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
setComposerDropActive(true);
|
|
463
|
+
});
|
|
464
|
+
elements.composerForm.addEventListener("dragover", (event) => {
|
|
465
|
+
if (!hasDraggedFiles(event.dataTransfer))
|
|
466
|
+
return;
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
if (event.dataTransfer)
|
|
469
|
+
event.dataTransfer.dropEffect = "copy";
|
|
470
|
+
setComposerDropActive(true);
|
|
471
|
+
});
|
|
472
|
+
elements.composerForm.addEventListener("dragleave", (event) => {
|
|
473
|
+
const nextTarget = event.relatedTarget;
|
|
474
|
+
if (nextTarget instanceof Node && elements.composerForm.contains(nextTarget))
|
|
475
|
+
return;
|
|
476
|
+
setComposerDropActive(false);
|
|
477
|
+
});
|
|
478
|
+
elements.composerForm.addEventListener("drop", (event) => {
|
|
479
|
+
if (!hasDraggedFiles(event.dataTransfer))
|
|
480
|
+
return;
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
event.stopPropagation();
|
|
483
|
+
setComposerDropActive(false);
|
|
484
|
+
addComposerFileReferences(droppedComposerFileReferences(event.dataTransfer));
|
|
485
|
+
});
|
|
486
|
+
window.addEventListener("dragend", () => setComposerDropActive(false));
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The same references a drop produces, chosen from a native dialog instead.
|
|
490
|
+
* Dragging is not always possible — a folder the user reaches through a file
|
|
491
|
+
* manager's tree, a window that cannot be arranged side by side — and the
|
|
492
|
+
* renderer has no path of its own to offer.
|
|
493
|
+
*
|
|
494
|
+
* Files and directories are separate buttons because they are separate dialogs:
|
|
495
|
+
* the GTK chooser answers a request for both with directories only.
|
|
496
|
+
*/
|
|
497
|
+
async function chooseAttachments(kind) {
|
|
498
|
+
let chosen;
|
|
499
|
+
try {
|
|
500
|
+
chosen = await window.pspore.pickAttachments(kind);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
store.setNotice(`无法打开选择器:${toError(error).message}`);
|
|
504
|
+
render();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (chosen.length === 0)
|
|
508
|
+
return;
|
|
509
|
+
addComposerFileReferences(chosen.flatMap((entry) => {
|
|
510
|
+
const reference = composerFileReference(entry.path, entry.kind);
|
|
511
|
+
return reference ? [reference] : [];
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
/** Merges references into the composer and says what happened, for drops and the chooser alike. */
|
|
515
|
+
function addComposerFileReferences(incoming) {
|
|
516
|
+
const merged = mergeComposerFileReferences(composerFileReferences, incoming);
|
|
517
|
+
composerFileReferences = merged.references;
|
|
518
|
+
renderComposerFileReferences();
|
|
519
|
+
if (merged.added > 0) {
|
|
520
|
+
const ignored = merged.skipped > 0 ? `;另有 ${merged.skipped} 个重复引用或超出上限` : "";
|
|
521
|
+
store.setNotice(`已添加 ${merged.added} 个文件/目录引用,仅会发送路径${ignored}`);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
store.setNotice(incoming.length > 0 ? "这些路径已经引用,未重复添加" : "未能获取本地文件或目录路径");
|
|
525
|
+
}
|
|
526
|
+
render();
|
|
527
|
+
elements.composerInput.focus();
|
|
528
|
+
}
|
|
529
|
+
function hasDraggedFiles(transfer) {
|
|
530
|
+
if (!transfer)
|
|
531
|
+
return false;
|
|
532
|
+
return Array.from(transfer.types).includes("Files") || Array.from(transfer.items).some((item) => item.kind === "file");
|
|
533
|
+
}
|
|
534
|
+
function setComposerDropActive(active) {
|
|
535
|
+
elements.composerForm.dataset["dropActive"] = String(active);
|
|
536
|
+
elements.composerDropOverlay.hidden = !active;
|
|
537
|
+
}
|
|
538
|
+
function droppedComposerFileReferences(transfer) {
|
|
539
|
+
if (!transfer)
|
|
540
|
+
return [];
|
|
541
|
+
const references = new Map();
|
|
542
|
+
const add = (file, kind) => {
|
|
543
|
+
if (!file)
|
|
544
|
+
return;
|
|
545
|
+
try {
|
|
546
|
+
const reference = composerFileReference(window.pspore.getPathForFile(file), kind);
|
|
547
|
+
if (reference && !references.has(reference.path))
|
|
548
|
+
references.set(reference.path, reference);
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
// Browser-created Files have no operating-system path and are not valid references.
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
for (const item of Array.from(transfer.items)) {
|
|
555
|
+
if (item.kind !== "file")
|
|
556
|
+
continue;
|
|
557
|
+
let kind = "file";
|
|
558
|
+
try {
|
|
559
|
+
if (item.webkitGetAsEntry()?.isDirectory)
|
|
560
|
+
kind = "directory";
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// The path remains usable even when Chromium cannot expose entry metadata.
|
|
564
|
+
}
|
|
565
|
+
add(item.getAsFile(), kind);
|
|
566
|
+
}
|
|
567
|
+
for (const file of Array.from(transfer.files))
|
|
568
|
+
add(file, "file");
|
|
569
|
+
return [...references.values()];
|
|
570
|
+
}
|
|
571
|
+
function renderComposerFileReferences() {
|
|
572
|
+
const nodes = composerFileReferences.map((reference) => {
|
|
573
|
+
const item = element("div", "composer-reference");
|
|
574
|
+
item.setAttribute("role", "listitem");
|
|
575
|
+
item.setAttribute("aria-label", `${reference.kind === "directory" ? "目录" : "文件"} ${reference.path}`);
|
|
576
|
+
item.dataset["kind"] = reference.kind;
|
|
577
|
+
const kind = element("span", "composer-reference-kind");
|
|
578
|
+
kind.textContent = reference.kind === "directory" ? "目录" : "文件";
|
|
579
|
+
const path = element("span", "composer-reference-path");
|
|
580
|
+
path.textContent = composerFileReferenceName(reference.path);
|
|
581
|
+
path.title = reference.path;
|
|
582
|
+
const remove = element("button", "composer-reference-remove");
|
|
583
|
+
remove.type = "button";
|
|
584
|
+
remove.textContent = "×";
|
|
585
|
+
remove.title = "移除引用";
|
|
586
|
+
remove.setAttribute("aria-label", `移除引用 ${composerFileReferenceName(reference.path)}`);
|
|
587
|
+
remove.addEventListener("click", () => {
|
|
588
|
+
composerFileReferences = composerFileReferences.filter((candidate) => candidate.path !== reference.path);
|
|
589
|
+
renderComposerFileReferences();
|
|
590
|
+
elements.composerInput.focus();
|
|
591
|
+
});
|
|
592
|
+
item.append(kind, path, remove);
|
|
593
|
+
return item;
|
|
594
|
+
});
|
|
595
|
+
const added = nodes.length > lastReferenceCount;
|
|
596
|
+
lastReferenceCount = nodes.length;
|
|
597
|
+
elements.composerReferences.replaceChildren(...nodes);
|
|
598
|
+
elements.composerReferences.hidden = nodes.length === 0;
|
|
599
|
+
if (added)
|
|
600
|
+
popChips(nodes);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Re-lays the transcript out after the window changed size.
|
|
604
|
+
*
|
|
605
|
+
* The transcript is a variable-height virtual list, and every height in its index
|
|
606
|
+
* was measured at the previous width. A resize invalidates all of them at once:
|
|
607
|
+
* messages rewrap, so the spacers keep reserving space for a layout that no
|
|
608
|
+
* longer exists and the conversation stops tracking the window. Nothing else
|
|
609
|
+
* re-rendered on resize either — `syncResponsivePanels` returns early while the
|
|
610
|
+
* breakpoint is unchanged, so dragging a window within one band redrew nothing.
|
|
611
|
+
*
|
|
612
|
+
* Debounced because a drag emits a resize per frame, and re-measuring the whole
|
|
613
|
+
* transcript on each one would fight the drag.
|
|
614
|
+
*/
|
|
615
|
+
let resizeReflowTimer;
|
|
616
|
+
function scheduleResizeReflow() {
|
|
617
|
+
if (resizeReflowTimer !== undefined)
|
|
618
|
+
window.clearTimeout(resizeReflowTimer);
|
|
619
|
+
resizeReflowTimer = window.setTimeout(() => {
|
|
620
|
+
resizeReflowTimer = undefined;
|
|
621
|
+
// Dropping the index rather than re-measuring what is on screen: the items
|
|
622
|
+
// outside the viewport rewrapped too, and their stale heights are what put
|
|
623
|
+
// the scroll extent wrong. `messageSource` is cleared with it because the
|
|
624
|
+
// render only re-syncs the index when the messages themselves changed, and a
|
|
625
|
+
// fresh index that is never synced holds no items at all.
|
|
626
|
+
transcriptState.heights = new VirtualHeightIndex(180);
|
|
627
|
+
transcriptState.messageSource = undefined;
|
|
628
|
+
render();
|
|
629
|
+
}, RESIZE_REFLOW_MS);
|
|
630
|
+
}
|
|
631
|
+
/** Long enough that a drag settles first, short enough to feel immediate. */
|
|
632
|
+
const RESIZE_REFLOW_MS = 120;
|
|
243
633
|
function syncResponsivePanels(force) {
|
|
244
|
-
|
|
634
|
+
// Only a full desktop viewport has room for all three columns; narrower windows keep the conversation dominant.
|
|
635
|
+
const width = window.innerWidth;
|
|
636
|
+
const nextMode = width <= 1_160 ? "narrow" : width <= 1_320 ? "compact" : width < 1_600 ? "wide" : "expansive";
|
|
245
637
|
if (!force && nextMode === responsiveMode)
|
|
246
638
|
return;
|
|
247
639
|
responsiveMode = nextMode;
|
|
248
640
|
setPanelState("navigation", nextMode !== "narrow");
|
|
249
|
-
setPanelState("inspector",
|
|
641
|
+
setPanelState("inspector", nextMode === "expansive");
|
|
250
642
|
}
|
|
251
643
|
function setPanelState(panel, open) {
|
|
252
644
|
document.documentElement.dataset[panel] = open ? "open" : "closed";
|
|
@@ -265,8 +657,13 @@ function selectInspectorTab(selected) {
|
|
|
265
657
|
tab.setAttribute("aria-selected", String(active));
|
|
266
658
|
tab.tabIndex = active ? 0 : -1;
|
|
267
659
|
}
|
|
268
|
-
for (const view of elements.inspectorViews)
|
|
269
|
-
|
|
660
|
+
for (const view of elements.inspectorViews) {
|
|
661
|
+
const active = view.dataset["inspectorPanel"] === selected;
|
|
662
|
+
const becameVisible = active && view.hidden;
|
|
663
|
+
view.hidden = !active;
|
|
664
|
+
if (becameVisible)
|
|
665
|
+
revealPanel(view);
|
|
666
|
+
}
|
|
270
667
|
}
|
|
271
668
|
function setAgentCompose(open) {
|
|
272
669
|
elements.agentCompose.hidden = !open;
|
|
@@ -279,34 +676,179 @@ async function openSettings() {
|
|
|
279
676
|
return;
|
|
280
677
|
settingsOpen = true;
|
|
281
678
|
elements.settingsOverlay.hidden = false;
|
|
679
|
+
showOverlayMotion(elements.settingsOverlay, elements.settingsDialog);
|
|
282
680
|
elements.settingsStatus.textContent = "读取设置中…";
|
|
283
681
|
elements.settingsSave.disabled = true;
|
|
284
682
|
updateAppInert();
|
|
285
683
|
requestAnimationFrame(() => elements.settingsClose.focus());
|
|
286
684
|
try {
|
|
287
|
-
const settings = await
|
|
685
|
+
const [settings, indexing] = await Promise.all([
|
|
686
|
+
window.pspore.getSettings(),
|
|
687
|
+
window.pspore.getIndexing(),
|
|
688
|
+
]);
|
|
288
689
|
populateSettings(settings);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
: "尚未通过桌面设置保存 API Key。";
|
|
690
|
+
populateIndexingSettings(indexing);
|
|
691
|
+
elements.settingsStatus.textContent = settingsStatus(settings);
|
|
292
692
|
elements.settingsSave.disabled = false;
|
|
293
693
|
}
|
|
294
694
|
catch (error) {
|
|
295
695
|
elements.settingsStatus.textContent = `读取失败:${toError(error).message}`;
|
|
296
696
|
}
|
|
297
697
|
}
|
|
698
|
+
function populateIndexingSettings(indexing) {
|
|
699
|
+
elements.settingsIndexingEnabled.checked = indexing.enabled;
|
|
700
|
+
elements.settingsIndexingInterval.value = String(indexing.intervalMinutes);
|
|
701
|
+
elements.settingsIndexingExt.value = indexing.extensions.join(",");
|
|
702
|
+
indexingDirectories = [...indexing.directories];
|
|
703
|
+
renderIndexingDirectories();
|
|
704
|
+
elements.settingsIndexingNote.textContent = indexingSettingsNote(indexing);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Says in the settings panel itself why a ticked checkbox may still not index
|
|
708
|
+
* anything, so it agrees with what the status panel reports.
|
|
709
|
+
*/
|
|
710
|
+
function indexingSettingsNote(indexing) {
|
|
711
|
+
const notes = [
|
|
712
|
+
indexing.indexerAvailable
|
|
713
|
+
? `索引程序:${indexing.indexerPath}`
|
|
714
|
+
: `本次发行版未包含索引程序(${indexing.indexerPath}),启用后会报告不可用。`,
|
|
715
|
+
];
|
|
716
|
+
if (indexing.enabled && indexing.directories.length === 0) {
|
|
717
|
+
notes.push("已启用,但还没有选择目录,索引不会运行。");
|
|
718
|
+
}
|
|
719
|
+
return notes.join(" ");
|
|
720
|
+
}
|
|
721
|
+
function renderIndexingDirectories() {
|
|
722
|
+
elements.settingsIndexingList.replaceChildren(...indexingDirectories.map((directory) => {
|
|
723
|
+
const row = element("div", "indexing-dir");
|
|
724
|
+
row.setAttribute("role", "listitem");
|
|
725
|
+
const path = element("code");
|
|
726
|
+
path.textContent = directory;
|
|
727
|
+
const remove = element("button", "quiet-text-button");
|
|
728
|
+
remove.type = "button";
|
|
729
|
+
remove.textContent = "移除";
|
|
730
|
+
remove.setAttribute("aria-label", `移除索引目录 ${directory}`);
|
|
731
|
+
remove.addEventListener("click", () => {
|
|
732
|
+
indexingDirectories = indexingDirectories.filter((candidate) => candidate !== directory);
|
|
733
|
+
renderIndexingDirectories();
|
|
734
|
+
});
|
|
735
|
+
row.append(path, remove);
|
|
736
|
+
return row;
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
async function addIndexingDirectory() {
|
|
740
|
+
// The renderer has no filesystem access, so the absolute path can only come
|
|
741
|
+
// from the main process's native chooser.
|
|
742
|
+
const directory = await window.pspore.pickIndexDirectory();
|
|
743
|
+
if (!directory || indexingDirectories.includes(directory))
|
|
744
|
+
return;
|
|
745
|
+
indexingDirectories = [...indexingDirectories, directory].sort();
|
|
746
|
+
renderIndexingDirectories();
|
|
747
|
+
popChips(Array.from(elements.settingsIndexingList.children));
|
|
748
|
+
}
|
|
298
749
|
function populateSettings(settings) {
|
|
299
750
|
elements.settingsProvider.value = settings.provider;
|
|
300
751
|
elements.settingsModel.value = settings.model;
|
|
301
752
|
elements.settingsBaseUrl.value = settings.baseUrl;
|
|
302
753
|
elements.settingsApi.value = settings.api;
|
|
303
754
|
elements.settingsThinking.value = settings.thinking;
|
|
755
|
+
elements.settingsPermissionMode.value = settings.permissionMode;
|
|
756
|
+
elements.settingsAutoCompaction.checked = settings.autoCompactionEnabled;
|
|
757
|
+
elements.settingsCompactionTrigger.value = String(settings.autoCompactionTriggerTokens);
|
|
758
|
+
elements.settingsCompactionReserve.value = String(settings.autoCompactionReserveTokens);
|
|
759
|
+
elements.settingsCompactionKeepRecent.value = String(settings.autoCompactionKeepRecentTokens);
|
|
760
|
+
populateSettingsPaths(settings);
|
|
761
|
+
configurePermissionFields();
|
|
304
762
|
configureProviderFields(false);
|
|
305
|
-
elements.settingsApiKey.
|
|
306
|
-
elements.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
763
|
+
resetSecretField(elements.settingsApiKey, elements.settingsKeyReveal, elements.settingsClearKey, settings.hasApiKey, "API Key");
|
|
764
|
+
resetSecretField(elements.settingsCookie, elements.settingsCookieReveal, elements.settingsClearCookie, settings.hasCookie, "Cookie");
|
|
765
|
+
renderCustomHeaders(settings.customHeaders);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* One row per extra header. A stored value is never sent back here, so the row
|
|
769
|
+
* says it exists and leaves the field blank: typing replaces it, leaving it
|
|
770
|
+
* alone keeps it, and removing the row deletes both the header and its file.
|
|
771
|
+
*/
|
|
772
|
+
function renderCustomHeaders(headers) {
|
|
773
|
+
elements.settingsHeaderList.replaceChildren();
|
|
774
|
+
for (const header of headers)
|
|
775
|
+
addCustomHeaderRow(header.name, header.hasValue);
|
|
776
|
+
}
|
|
777
|
+
function addCustomHeaderRow(name, hasValue) {
|
|
778
|
+
const row = element("div", "settings-header-row");
|
|
779
|
+
const nameField = document.createElement("input");
|
|
780
|
+
nameField.type = "text";
|
|
781
|
+
nameField.className = "settings-header-name";
|
|
782
|
+
nameField.value = name;
|
|
783
|
+
nameField.maxLength = 63;
|
|
784
|
+
nameField.placeholder = "X-Example";
|
|
785
|
+
nameField.spellcheck = false;
|
|
786
|
+
nameField.setAttribute("aria-label", "请求头名称");
|
|
787
|
+
const valueField = document.createElement("input");
|
|
788
|
+
valueField.type = "password";
|
|
789
|
+
valueField.className = "settings-header-value";
|
|
790
|
+
valueField.maxLength = 4_096;
|
|
791
|
+
valueField.autocomplete = "new-password";
|
|
792
|
+
valueField.spellcheck = false;
|
|
793
|
+
valueField.placeholder = hasValue ? "已配置,留空保留" : "值";
|
|
794
|
+
valueField.dataset["stored"] = hasValue ? "true" : "false";
|
|
795
|
+
valueField.setAttribute("aria-label", "请求头值");
|
|
796
|
+
const remove = document.createElement("button");
|
|
797
|
+
remove.type = "button";
|
|
798
|
+
remove.className = "quiet-text-button";
|
|
799
|
+
remove.textContent = "移除";
|
|
800
|
+
remove.addEventListener("click", () => row.remove());
|
|
801
|
+
row.append(nameField, valueField, remove);
|
|
802
|
+
elements.settingsHeaderList.append(row);
|
|
803
|
+
if (!hasValue)
|
|
804
|
+
nameField.focus();
|
|
805
|
+
}
|
|
806
|
+
function collectCustomHeaders() {
|
|
807
|
+
const headers = [];
|
|
808
|
+
for (const row of Array.from(elements.settingsHeaderList.querySelectorAll(".settings-header-row"))) {
|
|
809
|
+
const name = row.querySelector(".settings-header-name")?.value.trim() ?? "";
|
|
810
|
+
const field = row.querySelector(".settings-header-value");
|
|
811
|
+
if (!name)
|
|
812
|
+
continue;
|
|
813
|
+
const value = field?.value ?? "";
|
|
814
|
+
// A renamed row has no stored value under its new name, so it must carry one.
|
|
815
|
+
if (value)
|
|
816
|
+
headers.push({ name, value });
|
|
817
|
+
else
|
|
818
|
+
headers.push({ name });
|
|
819
|
+
}
|
|
820
|
+
return headers;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Secrets stay in files outside `config.json`, so the panel shows exactly where
|
|
824
|
+
* each one is read from. Only the path is ever rendered — never the value.
|
|
825
|
+
*/
|
|
826
|
+
function populateSettingsPaths(settings) {
|
|
827
|
+
elements.settingsConfigPath.textContent = settings.configPath;
|
|
828
|
+
elements.settingsModelsPath.textContent = settings.modelsPath;
|
|
829
|
+
elements.settingsApiKeyPath.textContent = settings.apiKeyPath;
|
|
830
|
+
elements.settingsCookiePath.textContent = settings.cookiePath;
|
|
831
|
+
elements.settingsApiKeyState.textContent = settings.hasApiKey ? "已配置" : "未配置";
|
|
832
|
+
elements.settingsApiKeyState.dataset["state"] = settings.hasApiKey ? "present" : "absent";
|
|
833
|
+
elements.settingsCookieState.textContent = settings.hasCookie ? "已配置" : "未配置";
|
|
834
|
+
elements.settingsCookieState.dataset["state"] = settings.hasCookie ? "present" : "absent";
|
|
835
|
+
}
|
|
836
|
+
function bindPathCopy(button, source) {
|
|
837
|
+
button.addEventListener("click", () => {
|
|
838
|
+
const path = source.textContent ?? "";
|
|
839
|
+
if (!path || path === "—")
|
|
840
|
+
return;
|
|
841
|
+
void navigator.clipboard.writeText(path).then(() => flashButtonLabel(button, "已复制"), () => flashButtonLabel(button, "复制失败"));
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
function flashButtonLabel(button, label) {
|
|
845
|
+
const original = button.dataset["label"] ?? button.textContent ?? "";
|
|
846
|
+
button.dataset["label"] = original;
|
|
847
|
+
button.textContent = label;
|
|
848
|
+
nudgeElement(button);
|
|
849
|
+
window.setTimeout(() => {
|
|
850
|
+
button.textContent = button.dataset["label"] ?? original;
|
|
851
|
+
}, 1_400);
|
|
310
852
|
}
|
|
311
853
|
async function saveSettings() {
|
|
312
854
|
if (savingSettings)
|
|
@@ -318,8 +860,16 @@ async function saveSettings() {
|
|
|
318
860
|
api: elements.settingsApi.value,
|
|
319
861
|
thinking: elements.settingsThinking.value,
|
|
320
862
|
customModel: elements.settingsProvider.value === "custom-openai",
|
|
863
|
+
permissionMode: elements.settingsPermissionMode.value,
|
|
864
|
+
autoCompactionEnabled: elements.settingsAutoCompaction.checked,
|
|
865
|
+
autoCompactionTriggerTokens: elements.settingsCompactionTrigger.valueAsNumber,
|
|
866
|
+
autoCompactionReserveTokens: elements.settingsCompactionReserve.valueAsNumber,
|
|
867
|
+
autoCompactionKeepRecentTokens: elements.settingsCompactionKeepRecent.valueAsNumber,
|
|
321
868
|
...(elements.settingsApiKey.value ? { apiKey: elements.settingsApiKey.value } : {}),
|
|
869
|
+
...(elements.settingsCookie.value ? { cookie: elements.settingsCookie.value } : {}),
|
|
870
|
+
customHeaders: collectCustomHeaders(),
|
|
322
871
|
clearApiKey: elements.settingsClearKey.checked,
|
|
872
|
+
clearCookie: elements.settingsClearCookie.checked,
|
|
323
873
|
};
|
|
324
874
|
savingSettings = true;
|
|
325
875
|
elements.settingsSave.disabled = true;
|
|
@@ -329,7 +879,15 @@ async function saveSettings() {
|
|
|
329
879
|
try {
|
|
330
880
|
const result = await window.pspore.saveSettings(update);
|
|
331
881
|
populateSettings(result.settings);
|
|
332
|
-
|
|
882
|
+
// Indexing is a separate config section; saving it here keeps one button.
|
|
883
|
+
const indexing = await window.pspore.saveIndexing({
|
|
884
|
+
enabled: elements.settingsIndexingEnabled.checked,
|
|
885
|
+
directories: [...indexingDirectories],
|
|
886
|
+
intervalMinutes: elements.settingsIndexingInterval.valueAsNumber,
|
|
887
|
+
extensions: elements.settingsIndexingExt.value.split(",").map((value) => value.trim()).filter(Boolean),
|
|
888
|
+
});
|
|
889
|
+
populateIndexingSettings(indexing.settings);
|
|
890
|
+
elements.settingsStatus.textContent = `${result.message} ${indexing.message}`;
|
|
333
891
|
}
|
|
334
892
|
catch (error) {
|
|
335
893
|
elements.settingsStatus.textContent = `保存失败:${toError(error).message}`;
|
|
@@ -341,6 +899,17 @@ async function saveSettings() {
|
|
|
341
899
|
elements.settingsClose.disabled = false;
|
|
342
900
|
}
|
|
343
901
|
}
|
|
902
|
+
const PERMISSION_MODE_HELP = {
|
|
903
|
+
ask_risky: "越界操作会先弹出批准请求。",
|
|
904
|
+
ask_all: "所有写入、执行与外部副作用类工具都会先请求批准。",
|
|
905
|
+
allow_all: "所有工具立即执行,不再产生批准请求。",
|
|
906
|
+
deny: "所有受控工具都会被直接拒绝,只保留纯读取能力。",
|
|
907
|
+
};
|
|
908
|
+
function configurePermissionFields() {
|
|
909
|
+
const mode = elements.settingsPermissionMode.value;
|
|
910
|
+
elements.settingsPermissionWarning.hidden = mode !== "allow_all";
|
|
911
|
+
elements.settingsPermissionHelp.textContent = PERMISSION_MODE_HELP[mode] ?? PERMISSION_MODE_HELP["ask_risky"] ?? "";
|
|
912
|
+
}
|
|
344
913
|
function configureProviderFields(resetModel) {
|
|
345
914
|
const provider = elements.settingsProvider.value;
|
|
346
915
|
const custom = provider === "custom-openai";
|
|
@@ -368,10 +937,15 @@ function closeSettings(restoreFocus = true) {
|
|
|
368
937
|
if (!settingsOpen || savingSettings)
|
|
369
938
|
return;
|
|
370
939
|
settingsOpen = false;
|
|
371
|
-
elements.settingsOverlay.hidden = true;
|
|
372
940
|
elements.settingsApiKey.value = "";
|
|
373
941
|
elements.settingsApiKey.type = "password";
|
|
942
|
+
elements.settingsCookie.value = "";
|
|
943
|
+
elements.settingsCookie.type = "password";
|
|
374
944
|
updateAppInert();
|
|
945
|
+
hideOverlayMotion(elements.settingsOverlay, elements.settingsDialog, () => {
|
|
946
|
+
if (!settingsOpen)
|
|
947
|
+
elements.settingsOverlay.hidden = true;
|
|
948
|
+
});
|
|
375
949
|
if (restoreFocus)
|
|
376
950
|
requestAnimationFrame(() => elements.settingsButton.focus());
|
|
377
951
|
}
|
|
@@ -401,12 +975,20 @@ function trapDialogFocus(event, container) {
|
|
|
401
975
|
}
|
|
402
976
|
}
|
|
403
977
|
async function submitComposer() {
|
|
404
|
-
|
|
978
|
+
let text;
|
|
979
|
+
try {
|
|
980
|
+
text = composeMessageWithFileReferences(elements.composerInput.value, composerFileReferences);
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
store.setNotice(toError(error).message);
|
|
984
|
+
render();
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
405
987
|
if (!text)
|
|
406
988
|
return;
|
|
989
|
+
nudgeElement(elements.send);
|
|
407
990
|
const snapshot = store.snapshot();
|
|
408
|
-
const
|
|
409
|
-
const intent = conversationIntent(snapshot.kernel, snapshot.selectedSessionId, text, mode === "steer" ? "steer" : mode === "follow_up" ? "follow_up" : "prompt");
|
|
991
|
+
const intent = conversationIntent(snapshot.kernel, snapshot.selectedSessionId, text, "auto");
|
|
410
992
|
let command;
|
|
411
993
|
if (intent.target === "agent") {
|
|
412
994
|
command = {
|
|
@@ -417,41 +999,88 @@ async function submitComposer() {
|
|
|
417
999
|
};
|
|
418
1000
|
}
|
|
419
1001
|
else {
|
|
420
|
-
command = { action:
|
|
1002
|
+
command = { action: "prompt", text: intent.text, sessionId: intent.sessionId };
|
|
421
1003
|
}
|
|
422
|
-
await perform(
|
|
1004
|
+
await perform(`发送到 ${intent.target === "agent" ? intent.agentId : shortId(intent.sessionId)}`, command, () => {
|
|
423
1005
|
elements.composerInput.value = "";
|
|
1006
|
+
composerFileReferences = [];
|
|
1007
|
+
renderComposerFileReferences();
|
|
424
1008
|
elements.composerInput.focus();
|
|
425
1009
|
});
|
|
426
1010
|
}
|
|
427
1011
|
async function perform(pendingNotice, command, onSuccess) {
|
|
428
|
-
store.setNotice(
|
|
1012
|
+
store.setNotice(`正在${pendingNotice}…`);
|
|
429
1013
|
render();
|
|
430
1014
|
try {
|
|
431
|
-
const result = await
|
|
432
|
-
store.setNotice(`${pendingNotice}
|
|
1015
|
+
const result = await dispatchAcrossReconnect(command);
|
|
1016
|
+
store.setNotice(`${pendingNotice}:完成`);
|
|
433
1017
|
await onSuccess?.(result);
|
|
434
1018
|
}
|
|
435
1019
|
catch (error) {
|
|
436
|
-
store.setNotice(`${pendingNotice}
|
|
1020
|
+
store.setNotice(`${pendingNotice}失败:${toError(error).message}`);
|
|
437
1021
|
}
|
|
438
1022
|
render();
|
|
439
1023
|
}
|
|
1024
|
+
/**
|
|
1025
|
+
* The shell keeps its controls enabled through a short reconnect, so a command
|
|
1026
|
+
* issued in that window must not simply fail. Only the "not connected" error is
|
|
1027
|
+
* retried: the bridge raises it before writing anything to the socket, so the
|
|
1028
|
+
* retry cannot duplicate a command the daemon already ran.
|
|
1029
|
+
*/
|
|
1030
|
+
async function dispatchAcrossReconnect(command) {
|
|
1031
|
+
try {
|
|
1032
|
+
return await window.pspore.dispatch(command);
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
const message = toError(error).message;
|
|
1036
|
+
if (!message.includes(DAEMON_NOT_CONNECTED_MESSAGE) || !store.snapshot().reconnecting)
|
|
1037
|
+
throw error;
|
|
1038
|
+
store.setNotice("守护进程正在重连,命令将在连接恢复后发送…");
|
|
1039
|
+
render();
|
|
1040
|
+
if (!(await waitForConnection()))
|
|
1041
|
+
throw error;
|
|
1042
|
+
return window.pspore.dispatch(command);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
function waitForConnection() {
|
|
1046
|
+
return new Promise((resolve) => {
|
|
1047
|
+
const deadline = Date.now() + CONNECTION_SETTLE_MS;
|
|
1048
|
+
const poll = () => {
|
|
1049
|
+
if (store.snapshot().connection.phase === "connected")
|
|
1050
|
+
resolve(true);
|
|
1051
|
+
else if (Date.now() >= deadline)
|
|
1052
|
+
resolve(false);
|
|
1053
|
+
else
|
|
1054
|
+
window.setTimeout(poll, 100);
|
|
1055
|
+
};
|
|
1056
|
+
poll();
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
440
1059
|
async function refreshSideData() {
|
|
441
1060
|
const snapshot = store.snapshot();
|
|
442
|
-
if (snapshot.
|
|
443
|
-
store.setNotice("
|
|
1061
|
+
if (snapshot.stableConnection.phase !== "connected") {
|
|
1062
|
+
store.setNotice("守护进程离线;重连后会自动刷新列表");
|
|
444
1063
|
render();
|
|
445
1064
|
return;
|
|
446
1065
|
}
|
|
447
|
-
store.setNotice("
|
|
1066
|
+
store.setNotice("正在刷新计划任务与受信资源…");
|
|
448
1067
|
render();
|
|
449
1068
|
const results = await Promise.allSettled([
|
|
450
1069
|
window.pspore.dispatch({ action: "jobs.list" }),
|
|
451
1070
|
window.pspore.dispatch({ action: "resources.list" }),
|
|
452
1071
|
window.pspore.dispatch({ action: "agents.list" }),
|
|
453
1072
|
window.pspore.dispatch({ action: "mcp.servers" }),
|
|
1073
|
+
window.pspore.dispatch({ action: "tools.list" }),
|
|
1074
|
+
window.pspore.dispatch({ action: "skills.catalog" }),
|
|
454
1075
|
]);
|
|
1076
|
+
const toolResult = results[4];
|
|
1077
|
+
if (toolResult?.status === "fulfilled" && Array.isArray(toolResult.value)) {
|
|
1078
|
+
builtinTools = toolResult.value.filter(isBuiltinTool);
|
|
1079
|
+
}
|
|
1080
|
+
const skillResult = results[5];
|
|
1081
|
+
if (skillResult?.status === "fulfilled" && Array.isArray(skillResult.value)) {
|
|
1082
|
+
skillCatalog = skillResult.value.filter(isSkillCatalogEntry);
|
|
1083
|
+
}
|
|
455
1084
|
const jobResult = results[0];
|
|
456
1085
|
const resourceResult = results[1];
|
|
457
1086
|
if (jobResult?.status === "fulfilled" && Array.isArray(jobResult.value)) {
|
|
@@ -461,7 +1090,7 @@ async function refreshSideData() {
|
|
|
461
1090
|
resources = resourceResult.value.filter(isResourceEntry);
|
|
462
1091
|
}
|
|
463
1092
|
const failures = results.filter((result) => result.status === "rejected");
|
|
464
|
-
store.setNotice(failures.length ?
|
|
1093
|
+
store.setNotice(failures.length ? `有 ${failures.length} 项列表刷新失败` : "列表已刷新");
|
|
465
1094
|
render();
|
|
466
1095
|
}
|
|
467
1096
|
function render() {
|
|
@@ -470,6 +1099,8 @@ function render() {
|
|
|
470
1099
|
renderSessions(snapshot);
|
|
471
1100
|
renderConversation(snapshot);
|
|
472
1101
|
renderActivities(snapshot.activities);
|
|
1102
|
+
renderRunActivity(snapshot);
|
|
1103
|
+
renderIndexing(snapshot);
|
|
473
1104
|
renderJobs(snapshot);
|
|
474
1105
|
renderIntegrations(snapshot);
|
|
475
1106
|
renderNotifications(snapshot);
|
|
@@ -480,49 +1111,247 @@ function render() {
|
|
|
480
1111
|
document.documentElement.dataset["renderSequence"] = String(renderSequence);
|
|
481
1112
|
document.documentElement.dataset["revision"] = String(snapshot.kernel.revision);
|
|
482
1113
|
document.documentElement.dataset["approval"] = Object.keys(snapshot.kernel.approvals).length ? "visible" : "hidden";
|
|
483
|
-
document.documentElement.dataset["connection"] = snapshot.
|
|
1114
|
+
document.documentElement.dataset["connection"] = snapshot.stableConnection.phase;
|
|
1115
|
+
document.documentElement.dataset["reconnecting"] = snapshot.reconnecting ? "true" : "false";
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The settle window is time-based, so a run that stays disconnected without
|
|
1119
|
+
* emitting further frames still has to surface. One timer, re-armed only while
|
|
1120
|
+
* a reconnect is being masked.
|
|
1121
|
+
*/
|
|
1122
|
+
function scheduleConnectionSettle(snapshot) {
|
|
1123
|
+
if (!snapshot.reconnecting) {
|
|
1124
|
+
if (settleTimer !== undefined) {
|
|
1125
|
+
window.clearTimeout(settleTimer);
|
|
1126
|
+
settleTimer = undefined;
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (settleTimer !== undefined)
|
|
1131
|
+
return;
|
|
1132
|
+
settleTimer = window.setTimeout(() => {
|
|
1133
|
+
settleTimer = undefined;
|
|
1134
|
+
if (store.settleConnection())
|
|
1135
|
+
scheduleRender();
|
|
1136
|
+
else
|
|
1137
|
+
scheduleConnectionSettle(store.snapshot());
|
|
1138
|
+
}, 250);
|
|
1139
|
+
}
|
|
1140
|
+
function scheduleRender() {
|
|
1141
|
+
if (renderFrame !== undefined)
|
|
1142
|
+
return;
|
|
1143
|
+
renderFrame = requestAnimationFrame(() => {
|
|
1144
|
+
renderFrame = undefined;
|
|
1145
|
+
render();
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
function scheduleTranscriptRender() {
|
|
1149
|
+
if (transcriptRenderFrame !== undefined)
|
|
1150
|
+
return;
|
|
1151
|
+
transcriptRenderFrame = requestAnimationFrame(() => {
|
|
1152
|
+
transcriptRenderFrame = undefined;
|
|
1153
|
+
if (initialized)
|
|
1154
|
+
renderConversation(store.snapshot());
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function handleTranscriptScroll() {
|
|
1158
|
+
transcriptState.followTail = isScrollNearEnd(elements.transcript.scrollHeight, elements.transcript.scrollTop, elements.transcript.clientHeight);
|
|
1159
|
+
if (elements.transcript.scrollTop < HISTORY_BACKFILL_TRIGGER_PX)
|
|
1160
|
+
void loadOlderHistory();
|
|
1161
|
+
scheduleTranscriptRender();
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Broadcasts carry only the newest part of a conversation, so scrolling to the
|
|
1165
|
+
* top asks the daemon for the page before it. The viewport is pinned to the
|
|
1166
|
+
* message the reader was looking at rather than to a scroll offset that the
|
|
1167
|
+
* prepended history has just moved.
|
|
1168
|
+
*/
|
|
1169
|
+
async function loadOlderHistory() {
|
|
1170
|
+
if (transcriptState.loadingHistory)
|
|
1171
|
+
return;
|
|
1172
|
+
const snapshot = store.snapshot();
|
|
1173
|
+
const sessionId = snapshot.selectedSessionId;
|
|
1174
|
+
const session = snapshot.kernel.sessions[sessionId];
|
|
1175
|
+
if (!session || session.messageOffset === 0)
|
|
1176
|
+
return;
|
|
1177
|
+
const from = Math.max(0, session.messageOffset - HISTORY_PAGE_SIZE);
|
|
1178
|
+
transcriptState.loadingHistory = true;
|
|
1179
|
+
try {
|
|
1180
|
+
const page = await dispatchAcrossReconnect({
|
|
1181
|
+
action: "session.transcript",
|
|
1182
|
+
sessionId,
|
|
1183
|
+
from,
|
|
1184
|
+
limit: session.messageOffset - from,
|
|
1185
|
+
});
|
|
1186
|
+
if (!isTranscriptPage(page) || page.sessionId !== store.snapshot().selectedSessionId)
|
|
1187
|
+
return;
|
|
1188
|
+
const anchor = elements.transcript.scrollHeight - elements.transcript.scrollTop;
|
|
1189
|
+
if (!store.applyTranscriptPage(page))
|
|
1190
|
+
return;
|
|
1191
|
+
render();
|
|
1192
|
+
elements.transcript.scrollTop = Math.max(0, elements.transcript.scrollHeight - anchor);
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
// A refused or disconnected backfill simply leaves the history where it is.
|
|
1196
|
+
}
|
|
1197
|
+
finally {
|
|
1198
|
+
transcriptState.loadingHistory = false;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function isTranscriptPage(value) {
|
|
1202
|
+
if (typeof value !== "object" || value === null)
|
|
1203
|
+
return false;
|
|
1204
|
+
const page = value;
|
|
1205
|
+
return typeof page["sessionId"] === "string"
|
|
1206
|
+
&& typeof page["from"] === "number"
|
|
1207
|
+
&& typeof page["count"] === "number"
|
|
1208
|
+
&& Array.isArray(page["messages"]);
|
|
484
1209
|
}
|
|
485
1210
|
function renderHeader(snapshot) {
|
|
486
|
-
|
|
1211
|
+
// The settled state is what the header shows: a reconnect that finishes inside
|
|
1212
|
+
// the settle window stays "ONLINE" so a long run does not strobe the badge.
|
|
1213
|
+
const connection = snapshot.stableConnection;
|
|
487
1214
|
elements.connectionBadge.className = `connection-badge ${connection.phase}`;
|
|
1215
|
+
elements.connectionBadge.dataset["reconnecting"] = snapshot.reconnecting ? "true" : "false";
|
|
488
1216
|
elements.connectionBadge.replaceChildren(element("i"), text(connection.phase === "connected" ? "ONLINE" : connection.phase.toUpperCase()));
|
|
1217
|
+
if (lastConnectionPhase && connection.phase !== lastConnectionPhase)
|
|
1218
|
+
pulseStatus(elements.connectionBadge);
|
|
1219
|
+
lastConnectionPhase = connection.phase;
|
|
1220
|
+
setReconnectingPulse(elements.connectionBadge, snapshot.reconnecting);
|
|
489
1221
|
elements.daemonInstance.textContent = connection.instanceId ? `daemon ${shortId(connection.instanceId)}` : "等待本地守护进程";
|
|
490
|
-
elements.connectionBanner
|
|
1222
|
+
toggleBannerMotion(elements.connectionBanner, connection.phase !== "connected");
|
|
491
1223
|
elements.connectionMessage.textContent = friendlyConnectionMessage(connection.message, connection.phase);
|
|
492
|
-
elements.revision.textContent = String(snapshot.kernel.revision);
|
|
493
1224
|
const session = snapshot.kernel.sessions[snapshot.selectedSessionId];
|
|
494
|
-
elements.
|
|
495
|
-
|
|
496
|
-
|
|
1225
|
+
tweenMetric(elements.revision, snapshot.kernel.revision, String);
|
|
1226
|
+
if (session?.context) {
|
|
1227
|
+
tweenMetric(elements.usage, session.context.tokens, (value) => `≈${formatTokens(value)}`);
|
|
1228
|
+
elements.usage.title = `当前上下文估算 ${session.context.tokens.toLocaleString()} / ${session.context.window.toLocaleString()} tokens${session.context.triggerTokens ? `;自动压缩阈值 ${session.context.triggerTokens.toLocaleString()}` : ""};累计用量 ${session.usage.total.toLocaleString()} tokens`;
|
|
1229
|
+
}
|
|
1230
|
+
else {
|
|
1231
|
+
elements.usage.textContent = "—";
|
|
1232
|
+
elements.usage.title = `当前上下文暂不可用;累计用量 ${(session?.usage.total ?? 0).toLocaleString()} tokens`;
|
|
1233
|
+
}
|
|
1234
|
+
tweenMetric(elements.totalUsage, session?.usage.total ?? 0, formatTokens);
|
|
1235
|
+
tweenMetric(elements.cache, (session?.usage.cacheRead ?? 0) + (session?.usage.cacheWrite ?? 0), formatTokens);
|
|
1236
|
+
crossfadeText(elements.notice, snapshot.notice);
|
|
497
1237
|
elements.refresh.disabled = connection.phase !== "connected";
|
|
1238
|
+
scheduleConnectionSettle(snapshot);
|
|
498
1239
|
}
|
|
499
1240
|
function renderSessions(snapshot) {
|
|
500
1241
|
const sessions = presentSessions(snapshot.kernel, snapshot.selectedSessionId);
|
|
501
|
-
const
|
|
1242
|
+
const connected = snapshot.stableConnection.phase === "connected";
|
|
1243
|
+
const nodes = sessions.map((session) => sessionRow(session, connected));
|
|
1244
|
+
const sessionSignature = sessions
|
|
1245
|
+
.map((session) => `${session.id}:${session.status}:${session.selected}:${session.id === armedSessionDeletion}`)
|
|
1246
|
+
.join("|");
|
|
502
1247
|
elements.sessionList.replaceChildren(...nodes);
|
|
503
|
-
|
|
1248
|
+
if (sessionSignature !== lastSessionSignature) {
|
|
1249
|
+
if (lastSessionSignature)
|
|
1250
|
+
revealListItems(nodes, { distance: 6 });
|
|
1251
|
+
lastSessionSignature = sessionSignature;
|
|
1252
|
+
}
|
|
1253
|
+
tweenMetric(elements.sessionCount, sessions.length, String);
|
|
1254
|
+
elements.newSession.disabled = snapshot.stableConnection.phase !== "connected";
|
|
504
1255
|
const child = selectedAgent(snapshot);
|
|
505
1256
|
elements.childControls.hidden = !child;
|
|
506
|
-
elements.joinAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.
|
|
507
|
-
elements.cancelAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.
|
|
1257
|
+
elements.joinAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.stableConnection.phase !== "connected";
|
|
1258
|
+
elements.cancelAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.stableConnection.phase !== "connected";
|
|
1259
|
+
const metrics = kernelMetrics(snapshot.kernel);
|
|
1260
|
+
elements.railRuns.textContent = `${metrics.activeRuns}/${metrics.runs}`;
|
|
1261
|
+
elements.railMcp.textContent = `${metrics.readyMcpServers}/${metrics.mcpServers}`;
|
|
1262
|
+
elements.railResources.textContent = `${metrics.activeResources}/${metrics.resources}`;
|
|
1263
|
+
elements.railApprovals.textContent = String(metrics.pendingApprovals);
|
|
1264
|
+
elements.railApprovals.classList.toggle("attention", metrics.pendingApprovals > 0);
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* One row of the session rail: the session button plus its delete control.
|
|
1268
|
+
*
|
|
1269
|
+
* The delete control cannot live inside the session button — a button may not
|
|
1270
|
+
* nest another — so the row wraps both and the control is positioned over the
|
|
1271
|
+
* button's trailing edge.
|
|
1272
|
+
*/
|
|
1273
|
+
function sessionRow(session, connected) {
|
|
1274
|
+
const row = element("div", `session-row${session.id === armedSessionDeletion ? " arming" : ""}`);
|
|
1275
|
+
row.append(sessionButton(session.id, session.title, session.status, session.depth, session.selected, session.active, session.createdAt));
|
|
1276
|
+
// Child agents belong to their root session and are cancelled, not deleted;
|
|
1277
|
+
// a placeholder row for a session the daemon does not hold has nothing to
|
|
1278
|
+
// delete either.
|
|
1279
|
+
if (session.kind === "main" && session.active) {
|
|
1280
|
+
row.append(sessionDeleteButton(session, connected));
|
|
1281
|
+
}
|
|
1282
|
+
return row;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Deleting a session destroys its transcript, so the first click only arms the
|
|
1286
|
+
* control and the second one confirms. An inline two-step control keeps the
|
|
1287
|
+
* confirmation in the rail, where the row being deleted is still visible.
|
|
1288
|
+
*/
|
|
1289
|
+
function sessionDeleteButton(session, connected) {
|
|
1290
|
+
const armed = session.id === armedSessionDeletion;
|
|
1291
|
+
const button = element("button", `session-delete${armed ? " armed" : ""}`);
|
|
1292
|
+
button.type = "button";
|
|
1293
|
+
button.disabled = !connected;
|
|
1294
|
+
button.textContent = armed ? "确认删除" : "✕";
|
|
1295
|
+
const label = session.id === "main" ? "main" : shortId(session.id);
|
|
1296
|
+
button.title = armed ? `确认删除会话 ${label}(包括聊天记录)` : `删除会话 ${label}`;
|
|
1297
|
+
button.setAttribute("aria-label", button.title);
|
|
1298
|
+
button.addEventListener("click", (event) => {
|
|
1299
|
+
event.stopPropagation();
|
|
1300
|
+
if (!armed) {
|
|
1301
|
+
armSessionDeletion(session.id);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
disarmSessionDeletion();
|
|
1305
|
+
void perform(`删除会话 ${label}`, { action: "session.delete", sessionId: session.id });
|
|
1306
|
+
});
|
|
1307
|
+
return button;
|
|
508
1308
|
}
|
|
509
|
-
|
|
1309
|
+
/** Arms one row at a time and disarms it again if the user walks away. */
|
|
1310
|
+
function armSessionDeletion(id) {
|
|
1311
|
+
if (armSessionDeletionTimer !== undefined)
|
|
1312
|
+
clearTimeout(armSessionDeletionTimer);
|
|
1313
|
+
armedSessionDeletion = id;
|
|
1314
|
+
armSessionDeletionTimer = window.setTimeout(() => {
|
|
1315
|
+
armedSessionDeletion = undefined;
|
|
1316
|
+
armSessionDeletionTimer = undefined;
|
|
1317
|
+
render();
|
|
1318
|
+
}, SESSION_DELETE_ARM_MS);
|
|
1319
|
+
render();
|
|
1320
|
+
}
|
|
1321
|
+
function disarmSessionDeletion() {
|
|
1322
|
+
if (armSessionDeletionTimer !== undefined)
|
|
1323
|
+
clearTimeout(armSessionDeletionTimer);
|
|
1324
|
+
armSessionDeletionTimer = undefined;
|
|
1325
|
+
armedSessionDeletion = undefined;
|
|
1326
|
+
}
|
|
1327
|
+
function sessionButton(id, title, status, depth, selected, active, createdAt) {
|
|
510
1328
|
const button = element("button", `session-item ${status}${selected ? " selected" : ""}`);
|
|
511
1329
|
button.type = "button";
|
|
512
1330
|
button.disabled = !active;
|
|
513
|
-
|
|
1331
|
+
// The rail draws the agent tree as hyphae branching off main; depth feeds both the indent and the connector.
|
|
1332
|
+
button.dataset["depth"] = String(depth);
|
|
1333
|
+
button.style.setProperty("--session-depth", String(depth));
|
|
514
1334
|
const copy = element("span", "session-copy");
|
|
515
1335
|
const strong = element("strong");
|
|
516
1336
|
strong.textContent = id === "main" ? "main" : shortId(id);
|
|
517
1337
|
const small = element("small");
|
|
518
1338
|
small.textContent = title;
|
|
519
1339
|
copy.append(strong, small);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
1340
|
+
// The creation time sits beside the id, which is what the list is ordered by,
|
|
1341
|
+
// so the ordering is legible instead of something the reader has to infer.
|
|
1342
|
+
if (createdAt !== undefined) {
|
|
1343
|
+
const when = element("time", "session-time");
|
|
1344
|
+
when.textContent = listTime(createdAt);
|
|
1345
|
+
when.setAttribute("datetime", createdAt);
|
|
1346
|
+
strong.append(when);
|
|
1347
|
+
}
|
|
1348
|
+
const statusMark = element("span", "session-status-mark");
|
|
1349
|
+
statusMark.textContent = status;
|
|
1350
|
+
button.append(element("i", "node-dot"), copy, statusMark);
|
|
523
1351
|
button.addEventListener("click", () => {
|
|
524
1352
|
try {
|
|
525
1353
|
store.selectSession(id);
|
|
1354
|
+
articles.onSessionChanged();
|
|
526
1355
|
render();
|
|
527
1356
|
}
|
|
528
1357
|
catch (error) {
|
|
@@ -535,91 +1364,671 @@ function sessionButton(id, title, status, depth, selected, active) {
|
|
|
535
1364
|
function renderConversation(snapshot) {
|
|
536
1365
|
const session = snapshot.kernel.sessions[snapshot.selectedSessionId];
|
|
537
1366
|
const child = selectedAgent(snapshot);
|
|
538
|
-
elements.sessionTitle.textContent = child
|
|
1367
|
+
elements.sessionTitle.textContent = child
|
|
1368
|
+
? `${shortId(child.id)} · ${child.objective}`
|
|
1369
|
+
: session?.rootSession?.title ?? (snapshot.selectedSessionId === "main" ? "main" : shortId(snapshot.selectedSessionId));
|
|
539
1370
|
const status = session?.status ?? (child?.status ?? "idle");
|
|
540
1371
|
elements.sessionStatus.textContent = status.toUpperCase();
|
|
541
1372
|
elements.sessionStatus.className = `status-chip ${status}`;
|
|
1373
|
+
if (lastSessionStatus && status !== lastSessionStatus)
|
|
1374
|
+
flipStatusChip(elements.sessionStatus);
|
|
1375
|
+
lastSessionStatus = status;
|
|
542
1376
|
elements.modelName.textContent = session?.model ? `${session.model.provider}/${session.model.model}` : "未配置模型";
|
|
543
|
-
elements.composerInput.placeholder = `向 ${child?.id ??
|
|
544
|
-
const connected = snapshot.
|
|
1377
|
+
elements.composerInput.placeholder = `向 ${child?.id ?? shortId(snapshot.selectedSessionId)} 发送消息…`;
|
|
1378
|
+
const connected = snapshot.stableConnection.phase === "connected";
|
|
545
1379
|
elements.composerInput.disabled = !connected;
|
|
546
|
-
elements.composerMode.disabled = !connected;
|
|
547
1380
|
elements.send.disabled = !connected;
|
|
548
1381
|
elements.abort.disabled = !connected || (!child && session?.status !== "running");
|
|
549
1382
|
elements.compact.disabled = !connected || Boolean(child) || !session;
|
|
1383
|
+
renderMessageQueue(snapshot, child ? undefined : session);
|
|
550
1384
|
const transcript = elements.transcript;
|
|
551
|
-
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 120;
|
|
552
1385
|
const messages = session?.messages ?? [];
|
|
553
|
-
const content = messages.map((message) => renderMessage(message));
|
|
554
1386
|
const streaming = snapshot.streamingText[snapshot.selectedSessionId];
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
1387
|
+
const thinking = snapshot.streamingThinking[snapshot.selectedSessionId];
|
|
1388
|
+
const sessionChanged = transcriptState.sessionId !== snapshot.selectedSessionId;
|
|
1389
|
+
const offset = session?.messageOffset ?? 0;
|
|
1390
|
+
const previousEnd = transcriptState.end;
|
|
1391
|
+
if (sessionChanged) {
|
|
1392
|
+
if (transcriptState.measureFrame !== undefined)
|
|
1393
|
+
cancelAnimationFrame(transcriptState.measureFrame);
|
|
1394
|
+
transcriptState.heights = new VirtualHeightIndex(180);
|
|
1395
|
+
transcriptState.nodes.clear();
|
|
1396
|
+
processViews.clear();
|
|
1397
|
+
transcriptState.messageSource = undefined;
|
|
1398
|
+
transcriptState.items = [];
|
|
1399
|
+
transcriptState.hasStreaming = false;
|
|
1400
|
+
transcriptState.followTail = true;
|
|
1401
|
+
transcriptState.pinnedIndex = undefined;
|
|
1402
|
+
transcriptState.stream = undefined;
|
|
1403
|
+
}
|
|
1404
|
+
transcriptState.sessionId = snapshot.selectedSessionId;
|
|
1405
|
+
transcriptState.end = offset + messages.length;
|
|
1406
|
+
if (messages.length === 0 && !streaming && !thinking) {
|
|
1407
|
+
transcriptState.heights.sync([]);
|
|
1408
|
+
const empty = emptyTranscript(Boolean(child));
|
|
1409
|
+
transcript.replaceChildren(empty);
|
|
1410
|
+
if (sessionChanged)
|
|
1411
|
+
animateTranscriptHero(empty.querySelector(":scope > div"));
|
|
1412
|
+
sessionSearch.paint();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const hasStreaming = Boolean(streaming || thinking);
|
|
1416
|
+
const running = status === "running";
|
|
1417
|
+
const messagesChanged = transcriptState.messageSource !== messages || transcriptState.running !== running;
|
|
1418
|
+
if (messagesChanged) {
|
|
1419
|
+
transcriptState.messageSource = messages;
|
|
1420
|
+
transcriptState.running = running;
|
|
1421
|
+
// Keyed by absolute transcript position, so prepending older history keeps
|
|
1422
|
+
// the keys — and therefore the measured heights — of what is on screen.
|
|
1423
|
+
const resolvedMessages = messages.map((message, index) => {
|
|
1424
|
+
const address = message.id === undefined ? { index: offset + index } : { id: message.id };
|
|
1425
|
+
// A message restored in full replaces the cut copy the daemon published.
|
|
1426
|
+
const restored = fullMessageTexts.get(fullTextKey(snapshot.selectedSessionId, address));
|
|
1427
|
+
const resolved = restored === undefined ? message : { ...message, text: restored };
|
|
1428
|
+
return resolved;
|
|
1429
|
+
});
|
|
1430
|
+
transcriptState.items = conversationRows(resolvedMessages, offset, running);
|
|
1431
|
+
}
|
|
1432
|
+
if (messagesChanged || transcriptState.hasStreaming !== hasStreaming) {
|
|
1433
|
+
transcriptState.hasStreaming = hasStreaming;
|
|
1434
|
+
transcriptState.heights.sync([
|
|
1435
|
+
...transcriptState.items.map((item) => item.key),
|
|
1436
|
+
...(hasStreaming ? ["$stream"] : []),
|
|
1437
|
+
]);
|
|
1438
|
+
}
|
|
1439
|
+
const itemCount = transcriptState.items.length + (hasStreaming ? 1 : 0);
|
|
1440
|
+
sessionSearch.prepare(sessionChanged);
|
|
1441
|
+
const viewportHeight = Math.max(1, transcript.clientHeight);
|
|
1442
|
+
// A held selection outranks the tail: scrolling under a drag would keep
|
|
1443
|
+
// changing what is selected, which is what made copying mid-reply impossible.
|
|
1444
|
+
const shouldPin = transcriptState.followTail && !hasTranscriptSelection();
|
|
1445
|
+
const scrollTop = shouldPin
|
|
1446
|
+
? Math.max(0, transcriptState.heights.totalSize() - viewportHeight)
|
|
1447
|
+
: transcript.scrollTop;
|
|
1448
|
+
// An item holding a selection stays rendered even when the window drifts off
|
|
1449
|
+
// it: dropping its element is the one way left to collapse a selection the
|
|
1450
|
+
// reader is still dragging.
|
|
1451
|
+
const heldIndex = Number(selectedTranscriptNode()?.dataset["virtualIndex"] ?? Number.NaN);
|
|
1452
|
+
const range = transcriptState.heights.range(scrollTop, viewportHeight, undefined, Number.isInteger(heldIndex) ? heldIndex : transcriptState.pinnedIndex);
|
|
1453
|
+
const content = [sizeSpacer(transcriptState.spacerStart, range.paddingStart)];
|
|
1454
|
+
const visibleMessages = [];
|
|
1455
|
+
const appeared = [];
|
|
1456
|
+
for (let index = range.start; index < range.end; index += 1) {
|
|
1457
|
+
const item = transcriptState.items[index];
|
|
1458
|
+
const isStreaming = hasStreaming && index === transcriptState.items.length;
|
|
1459
|
+
if (!item && !isStreaming)
|
|
1460
|
+
continue;
|
|
1461
|
+
const key = isStreaming ? "$stream" : item?.key;
|
|
1462
|
+
if (!key)
|
|
1463
|
+
continue;
|
|
1464
|
+
let node;
|
|
1465
|
+
if (isStreaming) {
|
|
1466
|
+
const stream = transcriptState.stream ?? createStreamingView();
|
|
1467
|
+
if (transcriptState.stream !== stream) {
|
|
1468
|
+
transcriptState.stream = stream;
|
|
1469
|
+
appeared.push(stream.node);
|
|
1470
|
+
}
|
|
1471
|
+
stream.update(streaming ?? "", thinking ?? "");
|
|
1472
|
+
node = stream.node;
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
if (!item)
|
|
1476
|
+
continue;
|
|
1477
|
+
const before = item.kind === "process" ? processViews.get(key)?.node : transcriptState.nodes.get(messageVirtualKey(item.entry.message, item.entry.index));
|
|
1478
|
+
node = item.kind === "process" ? renderProcess(item, transcriptState.pinnedIndex === index) : cachedEntry(item.entry);
|
|
1479
|
+
if (before !== node)
|
|
1480
|
+
appeared.push(node);
|
|
1481
|
+
}
|
|
1482
|
+
node.dataset["virtualKey"] = key;
|
|
1483
|
+
node.dataset["virtualIndex"] = String(index);
|
|
1484
|
+
node.setAttribute("aria-posinset", String(index + 1));
|
|
1485
|
+
node.setAttribute("aria-setsize", String(itemCount));
|
|
1486
|
+
visibleMessages.push(node);
|
|
1487
|
+
content.push(node);
|
|
1488
|
+
}
|
|
1489
|
+
content.push(sizeSpacer(transcriptState.spacerEnd, range.paddingEnd));
|
|
1490
|
+
// The bubble is kept for the next reply, so it is emptied when this one ends
|
|
1491
|
+
// rather than left holding the blocks it last rendered.
|
|
1492
|
+
if (!hasStreaming)
|
|
1493
|
+
transcriptState.stream?.update("", "");
|
|
1494
|
+
transcript.setAttribute("aria-busy", String(hasStreaming));
|
|
1495
|
+
reconcileChildren(transcript, content);
|
|
1496
|
+
if (sessionChanged) {
|
|
1497
|
+
animateMessagesIn(visibleMessages.slice(-14), true);
|
|
1498
|
+
}
|
|
1499
|
+
else if (transcriptState.end > previousEnd) {
|
|
1500
|
+
// Compared at absolute positions: a history backfill grows the list without
|
|
1501
|
+
// adding anything new at the tail, and must not replay the entrance. Only
|
|
1502
|
+
// nodes built for this render are animated — one already on screen would be
|
|
1503
|
+
// faded back in from nothing every time the tail grows.
|
|
1504
|
+
const firstAppended = transcriptState.items.length - (transcriptState.end - previousEnd);
|
|
1505
|
+
animateMessagesIn(visibleMessages.filter((node) => appeared.includes(node) && Number(node.dataset["virtualIndex"]) >= firstAppended), false);
|
|
1506
|
+
}
|
|
1507
|
+
if (shouldPin)
|
|
1508
|
+
transcript.scrollTo({ top: transcript.scrollHeight, behavior: "auto" });
|
|
1509
|
+
scheduleTranscriptMeasurement();
|
|
1510
|
+
sessionSearch.paint();
|
|
1511
|
+
}
|
|
1512
|
+
function currentSessionTexts(snapshot) {
|
|
1513
|
+
const texts = transcriptState.items.map(conversationRowText);
|
|
1514
|
+
const streaming = snapshot.streamingText[snapshot.selectedSessionId] ?? "";
|
|
1515
|
+
const thinking = snapshot.streamingThinking[snapshot.selectedSessionId] ?? "";
|
|
1516
|
+
if (streaming || thinking)
|
|
1517
|
+
texts.push(`${thinking}${thinking && streaming ? "\n" : ""}${streaming}`);
|
|
1518
|
+
return texts;
|
|
1519
|
+
}
|
|
1520
|
+
function cachedMessage(key, message, address) {
|
|
1521
|
+
const cached = transcriptState.nodes.get(key);
|
|
1522
|
+
if (cached) {
|
|
1523
|
+
transcriptState.nodes.delete(key);
|
|
1524
|
+
transcriptState.nodes.set(key, cached);
|
|
1525
|
+
return cached;
|
|
1526
|
+
}
|
|
1527
|
+
const node = renderMessage(message, address);
|
|
1528
|
+
return cacheMessageNode(key, node);
|
|
1529
|
+
}
|
|
1530
|
+
function cacheMessageNode(key, node) {
|
|
1531
|
+
transcriptState.nodes.set(key, node);
|
|
1532
|
+
while (transcriptState.nodes.size > 128) {
|
|
1533
|
+
const oldest = transcriptState.nodes.keys().next().value;
|
|
1534
|
+
if (oldest === undefined)
|
|
1535
|
+
break;
|
|
1536
|
+
transcriptState.nodes.delete(oldest);
|
|
1537
|
+
}
|
|
1538
|
+
return node;
|
|
1539
|
+
}
|
|
1540
|
+
function scheduleTranscriptMeasurement() {
|
|
1541
|
+
if (transcriptState.measureFrame !== undefined)
|
|
1542
|
+
cancelAnimationFrame(transcriptState.measureFrame);
|
|
1543
|
+
transcriptState.measureFrame = requestAnimationFrame(() => {
|
|
1544
|
+
transcriptState.measureFrame = undefined;
|
|
1545
|
+
let changed = false;
|
|
1546
|
+
for (const node of Array.from(elements.transcript.querySelectorAll("[data-virtual-key]"))) {
|
|
1547
|
+
const key = node.dataset["virtualKey"];
|
|
1548
|
+
if (!key)
|
|
1549
|
+
continue;
|
|
1550
|
+
const margin = Number.parseFloat(getComputedStyle(node).marginBottom) || 0;
|
|
1551
|
+
changed = transcriptState.heights.update(key, node.offsetHeight + margin) || changed;
|
|
1552
|
+
}
|
|
1553
|
+
if (transcriptState.followTail && !hasTranscriptSelection()) {
|
|
1554
|
+
elements.transcript.scrollTo({ top: elements.transcript.scrollHeight, behavior: "auto" });
|
|
1555
|
+
}
|
|
1556
|
+
if (changed)
|
|
1557
|
+
scheduleTranscriptRender();
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* One padding element per edge, kept for the life of the transcript and resized
|
|
1562
|
+
* in place. Recreating them would make every render a structural change, which
|
|
1563
|
+
* is what used to detach the messages between them.
|
|
1564
|
+
*/
|
|
1565
|
+
function virtualSpacer(edge) {
|
|
1566
|
+
const spacer = element("div", `virtual-spacer ${edge}`);
|
|
1567
|
+
spacer.style.height = "0px";
|
|
1568
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
1569
|
+
return spacer;
|
|
1570
|
+
}
|
|
1571
|
+
function sizeSpacer(spacer, height) {
|
|
1572
|
+
const next = `${Math.max(0, Math.round(height))}px`;
|
|
1573
|
+
if (spacer.style.height !== next)
|
|
1574
|
+
spacer.style.height = next;
|
|
1575
|
+
return spacer;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* True while the reader is holding a selection inside the transcript.
|
|
1579
|
+
*
|
|
1580
|
+
* Following the tail then would drag the viewport out from under them mid-drag,
|
|
1581
|
+
* so the pin stands down until the selection collapses — which a click anywhere
|
|
1582
|
+
* does.
|
|
1583
|
+
*/
|
|
1584
|
+
function hasTranscriptSelection() {
|
|
1585
|
+
return selectedTranscriptNode() !== undefined;
|
|
1586
|
+
}
|
|
1587
|
+
/** The rendered message the reader's selection starts in, if it is in the transcript. */
|
|
1588
|
+
function selectedTranscriptNode() {
|
|
1589
|
+
const selection = document.getSelection();
|
|
1590
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0)
|
|
1591
|
+
return undefined;
|
|
1592
|
+
for (const node of [selection.anchorNode, selection.focusNode]) {
|
|
1593
|
+
if (!node || !elements.transcript.contains(node))
|
|
1594
|
+
continue;
|
|
1595
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
1596
|
+
const held = element?.closest("[data-virtual-index]");
|
|
1597
|
+
if (held)
|
|
1598
|
+
return held;
|
|
1599
|
+
}
|
|
1600
|
+
return undefined;
|
|
1601
|
+
}
|
|
1602
|
+
function messageVirtualKey(message, index) {
|
|
1603
|
+
return `${index}:${textFingerprint(JSON.stringify(message))}`;
|
|
1604
|
+
}
|
|
1605
|
+
function cachedEntry(entry) {
|
|
1606
|
+
const { message, index } = entry;
|
|
1607
|
+
return cachedMessage(messageVirtualKey(message, index), message, message.id === undefined ? { index } : { id: message.id });
|
|
1608
|
+
}
|
|
1609
|
+
function rememberDisclosure(key, open) {
|
|
1610
|
+
disclosureStates.delete(key);
|
|
1611
|
+
disclosureStates.set(key, open);
|
|
1612
|
+
while (disclosureStates.size > 512) {
|
|
1613
|
+
const oldest = disclosureStates.keys().next().value;
|
|
1614
|
+
if (oldest === undefined)
|
|
1615
|
+
break;
|
|
1616
|
+
disclosureStates.delete(oldest);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function renderProcess(row, searchHit) {
|
|
1620
|
+
const stateKey = `${transcriptState.sessionId}:${row.key}`;
|
|
1621
|
+
let view = processViews.get(row.key);
|
|
1622
|
+
if (!view) {
|
|
1623
|
+
const node = element("article", "message process-message");
|
|
1624
|
+
const details = document.createElement("details");
|
|
1625
|
+
details.className = "process-details";
|
|
1626
|
+
const summary = element("summary", "process-summary");
|
|
1627
|
+
const body = element("div", "process-body");
|
|
1628
|
+
details.open = disclosureStates.get(`${stateKey}:${row.active}`) ?? row.active;
|
|
1629
|
+
details.append(summary, body);
|
|
1630
|
+
node.append(details);
|
|
1631
|
+
view = { node, details, summary, body, active: row.active };
|
|
1632
|
+
const current = view;
|
|
1633
|
+
details.addEventListener("toggle", () => {
|
|
1634
|
+
rememberDisclosure(`${stateKey}:${current.active}`, details.open);
|
|
1635
|
+
scheduleTranscriptMeasurement();
|
|
1636
|
+
scheduleTranscriptRender();
|
|
1637
|
+
});
|
|
1638
|
+
processViews.set(row.key, view);
|
|
1639
|
+
if (processViews.size > 128) {
|
|
1640
|
+
const oldest = processViews.keys().next().value;
|
|
1641
|
+
if (oldest !== undefined)
|
|
1642
|
+
processViews.delete(oldest);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (view.active !== row.active) {
|
|
1646
|
+
view.active = row.active;
|
|
1647
|
+
view.details.open = disclosureStates.get(`${stateKey}:${row.active}`) ?? row.active;
|
|
1648
|
+
}
|
|
1649
|
+
const calls = row.entries.flatMap(({ message }) => message.toolCalls ?? []);
|
|
1650
|
+
const ids = new Set(calls.map((call) => call.id));
|
|
1651
|
+
const results = new Map(row.entries.flatMap((entry) => entry.message.toolCallId ? [[entry.message.toolCallId, entry]] : []));
|
|
1652
|
+
const orphanResults = row.entries.filter(({ message }) => isToolMessage(message) && (!message.toolCallId || !ids.has(message.toolCallId)));
|
|
1653
|
+
const count = calls.length + orphanResults.length;
|
|
1654
|
+
const names = [...new Set([...calls.map((call) => call.name), ...orphanResults.map(({ message }) => message.toolName ?? "工具")])];
|
|
1655
|
+
const failed = row.entries.some(({ message }) => message.isError);
|
|
1656
|
+
const label = `${row.active ? "思考与执行中" : "思考与执行过程"}${count ? ` · ${count} 次工具调用` : ""}${names.length ? ` · ${names.join("、")}` : ""}${failed ? " · 含失败调用" : ""}`;
|
|
1657
|
+
if (view.summary.textContent !== label)
|
|
1658
|
+
view.summary.textContent = label;
|
|
1659
|
+
view.summary.title = label;
|
|
1660
|
+
view.node.dataset["active"] = String(row.active);
|
|
1661
|
+
if (searchHit)
|
|
1662
|
+
view.details.open = true;
|
|
1663
|
+
// A completed run can contain hundreds of steps; mount them only on demand.
|
|
1664
|
+
if (!view.details.open) {
|
|
1665
|
+
reconcileChildren(view.body, []);
|
|
1666
|
+
return view.node;
|
|
1667
|
+
}
|
|
1668
|
+
const children = [];
|
|
1669
|
+
for (const entry of row.entries) {
|
|
1670
|
+
const message = entry.message;
|
|
1671
|
+
if (isToolMessage(message)) {
|
|
1672
|
+
if (!message.toolCallId || !ids.has(message.toolCallId))
|
|
1673
|
+
children.push(cachedEntry(entry));
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
if (message.thinking || message.text.trim())
|
|
1677
|
+
children.push(cachedEntry({ ...entry, message: { ...message, toolCalls: [] } }));
|
|
1678
|
+
for (const call of message.toolCalls ?? []) {
|
|
1679
|
+
const result = results.get(call.id);
|
|
1680
|
+
const key = `call:${call.id}:${textFingerprint(call.input)}:${result ? messageVirtualKey(result.message, result.index) : row.active}`;
|
|
1681
|
+
let card = transcriptState.nodes.get(key);
|
|
1682
|
+
if (!card) {
|
|
1683
|
+
card = renderToolCall(call, result?.message, result ? (result.message.id ? { id: result.message.id } : { index: result.index }) : undefined, row.active);
|
|
1684
|
+
cacheMessageNode(key, card);
|
|
1685
|
+
}
|
|
1686
|
+
children.push(card);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
for (const child of children) {
|
|
1690
|
+
delete child.dataset["virtualKey"];
|
|
1691
|
+
delete child.dataset["virtualIndex"];
|
|
1692
|
+
child.removeAttribute("aria-posinset");
|
|
1693
|
+
child.removeAttribute("aria-setsize");
|
|
1694
|
+
}
|
|
1695
|
+
reconcileChildren(view.body, children);
|
|
1696
|
+
return view.node;
|
|
1697
|
+
}
|
|
1698
|
+
function renderToolCall(call, result, address, active) {
|
|
1699
|
+
const card = element("section", "tool-call");
|
|
1700
|
+
const details = document.createElement("details");
|
|
1701
|
+
const stateKey = `${transcriptState.sessionId}:tool:${call.id}`;
|
|
1702
|
+
details.open = disclosureStates.get(stateKey) ?? !result;
|
|
1703
|
+
const summary = element("summary", "tool-call-summary");
|
|
1704
|
+
const name = element("strong", "tool-call-name");
|
|
1705
|
+
name.textContent = call.name;
|
|
1706
|
+
const target = element("span", "tool-call-target");
|
|
1707
|
+
target.textContent = toolCallSummary(call);
|
|
1708
|
+
target.title = target.textContent;
|
|
1709
|
+
const status = element("span", `tool-call-status${result?.isError ? " error" : ""}`);
|
|
1710
|
+
status.textContent = result ? (result.isError ? "失败" : "完成") : active ? "执行中" : "未完成";
|
|
1711
|
+
summary.append(name, target, status);
|
|
1712
|
+
const inputLabel = element("div", "tool-section-label");
|
|
1713
|
+
inputLabel.textContent = "输入";
|
|
1714
|
+
const input = element("pre", "tool-call-input");
|
|
1715
|
+
input.textContent = call.input;
|
|
1716
|
+
details.append(summary, inputLabel, input);
|
|
1717
|
+
if (result) {
|
|
1718
|
+
const label = element("div", "tool-section-label");
|
|
1719
|
+
label.textContent = "输出";
|
|
1720
|
+
const output = element("pre", "tool-call-output");
|
|
1721
|
+
output.textContent = result.text || "(无输出)";
|
|
1722
|
+
details.append(label, output);
|
|
1723
|
+
const truncation = publishedTextTruncation(result.text);
|
|
1724
|
+
if (truncation && address)
|
|
1725
|
+
details.append(truncationNotice(address, truncation));
|
|
1726
|
+
}
|
|
1727
|
+
details.addEventListener("toggle", () => {
|
|
1728
|
+
rememberDisclosure(stateKey, details.open);
|
|
1729
|
+
scheduleTranscriptMeasurement();
|
|
1730
|
+
});
|
|
1731
|
+
card.append(details);
|
|
1732
|
+
// Preserve the document action even when the raw tool input/output is closed.
|
|
1733
|
+
if (result && parseArticleReceipt(result.text))
|
|
1734
|
+
card.append(renderMessage(result, address ?? { index: 0 }));
|
|
1735
|
+
return card;
|
|
1736
|
+
}
|
|
1737
|
+
function textFingerprint(value) {
|
|
1738
|
+
let hash = 2_166_136_261;
|
|
1739
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1740
|
+
hash ^= value.charCodeAt(index);
|
|
1741
|
+
hash = Math.imul(hash, 16_777_619);
|
|
1742
|
+
}
|
|
1743
|
+
return (hash >>> 0).toString(36);
|
|
1744
|
+
}
|
|
1745
|
+
function renderMessageQueue(snapshot, session) {
|
|
1746
|
+
const direct = session?.queue.steering ?? [];
|
|
1747
|
+
const queued = session?.queue.followUp ?? [];
|
|
1748
|
+
elements.messageQueue.hidden = direct.length + queued.length === 0;
|
|
1749
|
+
if (direct.length + queued.length === 0) {
|
|
1750
|
+
elements.messageQueue.replaceChildren();
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const directNodes = direct.map((message) => {
|
|
1754
|
+
const row = element("article", "queue-message direct");
|
|
1755
|
+
row.dataset["queueMode"] = "direct";
|
|
1756
|
+
const state = element("span");
|
|
1757
|
+
state.textContent = "直接发送消息";
|
|
1758
|
+
state.title = "等待当前工具步骤结束后发送";
|
|
1759
|
+
const copy = element("p");
|
|
1760
|
+
copy.textContent = message;
|
|
1761
|
+
copy.title = message;
|
|
1762
|
+
row.append(state, copy);
|
|
1763
|
+
return row;
|
|
1764
|
+
});
|
|
1765
|
+
const queuedNodes = queued.map((message, index) => {
|
|
1766
|
+
const row = element("article", "queue-message");
|
|
1767
|
+
row.dataset["queueMode"] = "follow-up";
|
|
1768
|
+
const state = element("span");
|
|
1769
|
+
state.textContent = "继续排队";
|
|
1770
|
+
const copy = element("p");
|
|
1771
|
+
copy.textContent = message;
|
|
1772
|
+
copy.title = message;
|
|
1773
|
+
const sendNow = actionButton("直接发送消息", "ghost-button", () => {
|
|
1774
|
+
void perform("直接发送消息", {
|
|
1775
|
+
action: "queue.send-now",
|
|
1776
|
+
sessionId: snapshot.selectedSessionId,
|
|
1777
|
+
index,
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
sendNow.disabled = snapshot.stableConnection.phase !== "connected";
|
|
1781
|
+
row.append(state, copy, sendNow);
|
|
1782
|
+
return row;
|
|
1783
|
+
});
|
|
1784
|
+
elements.messageQueue.replaceChildren(...directNodes, ...queuedNodes);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* What a first conversation should look like: each one runs real tools, comes
|
|
1788
|
+
* back in seconds, and needs no approval under the default policy. The first
|
|
1789
|
+
* ends in the document panel, because a written deliverable is the part of this
|
|
1790
|
+
* app nothing else on the screen explains.
|
|
1791
|
+
*/
|
|
1792
|
+
const MAIN_SESSION_EXAMPLES = [
|
|
1793
|
+
{
|
|
1794
|
+
label: "把这台机器的 CPU、内存、磁盘和系统版本整理成一份可导出的说明文档",
|
|
1795
|
+
prompt: "把这台机器的 CPU、内存、磁盘和系统版本整理成一份可导出的说明文档",
|
|
1796
|
+
},
|
|
1797
|
+
{
|
|
1798
|
+
label: "看看现在最占内存的 10 个进程,说说哪些可以关掉",
|
|
1799
|
+
prompt: "看看现在最占内存的 10 个进程,说说哪些可以关掉",
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
// A template rather than a prompt. Clicking it also attaches the desktop,
|
|
1803
|
+
// so the reference a dropped folder produces is already on screen: the way
|
|
1804
|
+
// to point this agent at a directory is visible before it is explained.
|
|
1805
|
+
label: "分析一个目录里都有什么 —— 已附上桌面,也可以拖入别的文件夹",
|
|
1806
|
+
prompt: "分析这个目录里都有什么、哪些可以清理,把结论整理成文档:",
|
|
1807
|
+
attach: "desktop",
|
|
1808
|
+
},
|
|
1809
|
+
];
|
|
1810
|
+
function emptyTranscript(isChild) {
|
|
1811
|
+
const empty = element("div", "empty-transcript");
|
|
1812
|
+
const inner = element("div");
|
|
1813
|
+
const heading = element("strong");
|
|
1814
|
+
heading.textContent = isChild ? "子 Agent 尚未产生消息" : "在这台机器上开始一件事";
|
|
1815
|
+
const body = element("span");
|
|
1816
|
+
body.textContent = isChild
|
|
1817
|
+
? "可以继续发送消息推进它;运行中发送的内容会自动排队。"
|
|
1818
|
+
: "pSpore 在本地运行:读写文件、执行命令、安排定时任务;越界操作会先向你请求批准。";
|
|
1819
|
+
inner.append(heading, body);
|
|
1820
|
+
if (!isChild) {
|
|
1821
|
+
const suggestions = element("div", "transcript-suggestions");
|
|
1822
|
+
for (const example of MAIN_SESSION_EXAMPLES) {
|
|
1823
|
+
suggestions.append(actionButton(example.label, "", () => {
|
|
1824
|
+
elements.composerInput.value = example.prompt;
|
|
1825
|
+
if (example.attach === "desktop" && desktopPlaces?.desktop) {
|
|
1826
|
+
const reference = composerFileReference(desktopPlaces.desktop, "directory");
|
|
1827
|
+
if (reference)
|
|
1828
|
+
addComposerFileReferences([reference]);
|
|
1829
|
+
}
|
|
1830
|
+
elements.composerInput.focus();
|
|
1831
|
+
// Leaves the caret at the end, where a dropped path is appended.
|
|
1832
|
+
elements.composerInput.setSelectionRange(example.prompt.length, example.prompt.length);
|
|
1833
|
+
}));
|
|
1834
|
+
}
|
|
1835
|
+
inner.append(suggestions);
|
|
1836
|
+
}
|
|
1837
|
+
empty.append(inner);
|
|
1838
|
+
return empty;
|
|
1839
|
+
}
|
|
1840
|
+
function renderMessage(message, address) {
|
|
573
1841
|
const role = normalizedRole(message.role);
|
|
574
1842
|
const article = element("article", `message ${role}`);
|
|
575
1843
|
const meta = element("div", "message-meta");
|
|
576
1844
|
const label = element("span");
|
|
577
1845
|
label.textContent = role === "assistant" ? "pSpore" : role;
|
|
578
1846
|
meta.append(element("i", "role-dot"), label);
|
|
1847
|
+
if (message.thinking) {
|
|
1848
|
+
const thought = element("section", "thinking-stream settled-thinking");
|
|
1849
|
+
const heading = element("header");
|
|
1850
|
+
const title = element("strong");
|
|
1851
|
+
title.textContent = "思考";
|
|
1852
|
+
heading.append(title);
|
|
1853
|
+
const body = element("div", "thinking-stream-body");
|
|
1854
|
+
body.textContent = message.thinking;
|
|
1855
|
+
thought.append(heading, body);
|
|
1856
|
+
article.append(thought);
|
|
1857
|
+
}
|
|
579
1858
|
if (role === "tool") {
|
|
1859
|
+
const receipt = parseArticleReceipt(message.text);
|
|
1860
|
+
if (receipt) {
|
|
1861
|
+
const card = element("div", "article-receipt");
|
|
1862
|
+
const heading = element("strong");
|
|
1863
|
+
heading.textContent = receipt.title ? `文稿 · ${receipt.title}` : "文稿";
|
|
1864
|
+
const version = element("span");
|
|
1865
|
+
version.textContent = `第 ${receipt.version} 版`;
|
|
1866
|
+
const open = document.createElement("button");
|
|
1867
|
+
open.className = "ghost-button";
|
|
1868
|
+
open.type = "button";
|
|
1869
|
+
open.textContent = "打开文稿";
|
|
1870
|
+
open.addEventListener("click", () => {
|
|
1871
|
+
void articles.open(receipt.id, receipt.version);
|
|
1872
|
+
});
|
|
1873
|
+
card.append(heading, version, open);
|
|
1874
|
+
article.append(meta, card);
|
|
1875
|
+
return article;
|
|
1876
|
+
}
|
|
580
1877
|
const details = element("details");
|
|
581
1878
|
const summary = element("summary");
|
|
582
|
-
summary.textContent =
|
|
1879
|
+
summary.textContent = `${message.toolName ?? "工具输出"}${message.isError ? " · 失败" : ""} · ${formatBytes(new TextEncoder().encode(message.text).byteLength)} · 点击展开`;
|
|
583
1880
|
const pre = element("pre");
|
|
584
|
-
pre.textContent = message.text || "
|
|
1881
|
+
pre.textContent = message.text || "(无输出)";
|
|
585
1882
|
details.append(summary, pre);
|
|
1883
|
+
details.addEventListener("toggle", scheduleTranscriptMeasurement);
|
|
586
1884
|
article.append(meta, details);
|
|
587
1885
|
}
|
|
1886
|
+
else if (role === "assistant" && message.text.trim() === "") {
|
|
1887
|
+
// A turn that only called a tool has no words of its own. The tool card
|
|
1888
|
+
// below already says what happened, so the empty shell is collapsed rather
|
|
1889
|
+
// than announced — with the article tool this is every writing turn.
|
|
1890
|
+
if (!message.thinking)
|
|
1891
|
+
article.classList.add("message-silent");
|
|
1892
|
+
}
|
|
588
1893
|
else {
|
|
589
|
-
const body = element("div", "message-body");
|
|
590
|
-
|
|
1894
|
+
const body = element("div", "message-body markdown");
|
|
1895
|
+
appendMarkdown(body, message.text || "(空消息)");
|
|
591
1896
|
article.append(meta, body);
|
|
592
1897
|
}
|
|
1898
|
+
const truncation = publishedTextTruncation(message.text);
|
|
1899
|
+
if (truncation)
|
|
1900
|
+
article.append(truncationNotice(address, truncation));
|
|
593
1901
|
return article;
|
|
594
1902
|
}
|
|
595
|
-
|
|
1903
|
+
/**
|
|
1904
|
+
* Offers the rest of a message the daemon had to cut. The publishing ceiling
|
|
1905
|
+
* keeps one enormous message from crowding out everything else on the wire, so
|
|
1906
|
+
* the full text is something a reader asks for rather than something every
|
|
1907
|
+
* broadcast carries.
|
|
1908
|
+
*/
|
|
1909
|
+
function truncationNotice(address, truncation) {
|
|
1910
|
+
const notice = element("div", "message-truncated");
|
|
1911
|
+
const summary = element("span");
|
|
1912
|
+
summary.textContent = `仅显示前 ${truncation.kept.toLocaleString("zh-CN")} / ${truncation.total.toLocaleString("zh-CN")} 个字符`;
|
|
1913
|
+
const button = element("button", "ghost-button");
|
|
1914
|
+
button.type = "button";
|
|
1915
|
+
button.textContent = "载入全文";
|
|
1916
|
+
button.addEventListener("click", () => {
|
|
1917
|
+
button.disabled = true;
|
|
1918
|
+
button.textContent = "载入中…";
|
|
1919
|
+
void loadFullMessage(store.snapshot().selectedSessionId, address).then((loaded) => {
|
|
1920
|
+
// A successful load re-renders the message without this notice; a failed
|
|
1921
|
+
// one leaves it on screen, so the button has to become usable again.
|
|
1922
|
+
if (loaded)
|
|
1923
|
+
return;
|
|
1924
|
+
button.disabled = false;
|
|
1925
|
+
button.textContent = "重试载入全文";
|
|
1926
|
+
});
|
|
1927
|
+
});
|
|
1928
|
+
notice.append(summary, button);
|
|
1929
|
+
return notice;
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Pulls one message's untruncated text in slices no larger than the daemon
|
|
1933
|
+
* publishes, then re-renders with it in place. Slices rather than one frame,
|
|
1934
|
+
* because raising the ceiling for a single message raises it for every one.
|
|
1935
|
+
*/
|
|
1936
|
+
async function loadFullMessage(sessionId, address) {
|
|
1937
|
+
const key = fullTextKey(sessionId, address);
|
|
1938
|
+
if (loadingFullText.has(key) || fullMessageTexts.has(key))
|
|
1939
|
+
return false;
|
|
1940
|
+
loadingFullText.add(key);
|
|
1941
|
+
try {
|
|
1942
|
+
const restored = await readFullMessage((offset, limit) => dispatchAcrossReconnect({
|
|
1943
|
+
action: "session.message",
|
|
1944
|
+
sessionId,
|
|
1945
|
+
...address,
|
|
1946
|
+
offset,
|
|
1947
|
+
limit,
|
|
1948
|
+
}));
|
|
1949
|
+
// Slices that do not line up would splice two versions of one message
|
|
1950
|
+
// together, so the restore is abandoned rather than shown.
|
|
1951
|
+
if (restored === undefined)
|
|
1952
|
+
throw new Error("消息内容已变化");
|
|
1953
|
+
rememberFullText(key, restored);
|
|
1954
|
+
// The message array is unchanged, so the cached items have to be rebuilt
|
|
1955
|
+
// for the restored text to reach the screen.
|
|
1956
|
+
transcriptState.messageSource = undefined;
|
|
1957
|
+
store.setNotice("载入全文:完成");
|
|
1958
|
+
return true;
|
|
1959
|
+
}
|
|
1960
|
+
catch (error) {
|
|
1961
|
+
store.setNotice(`载入全文失败:${toError(error).message}`);
|
|
1962
|
+
return false;
|
|
1963
|
+
}
|
|
1964
|
+
finally {
|
|
1965
|
+
loadingFullText.delete(key);
|
|
1966
|
+
render();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function rememberFullText(key, value) {
|
|
1970
|
+
fullMessageTexts.set(key, value);
|
|
1971
|
+
while (fullMessageTexts.size > FULL_TEXT_CACHE_ENTRIES) {
|
|
1972
|
+
const oldest = fullMessageTexts.keys().next().value;
|
|
1973
|
+
if (oldest === undefined)
|
|
1974
|
+
break;
|
|
1975
|
+
fullMessageTexts.delete(oldest);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
function fullTextKey(sessionId, address) {
|
|
1979
|
+
return "id" in address ? `${sessionId}\u0000id:${address.id}` : `${sessionId}\u0000at:${String(address.index)}`;
|
|
1980
|
+
}
|
|
1981
|
+
function createStreamingView() {
|
|
596
1982
|
const article = element("article", "message assistant streaming");
|
|
597
1983
|
const meta = element("div", "message-meta");
|
|
598
1984
|
const label = element("span");
|
|
599
|
-
label.textContent = "pSpore · streaming";
|
|
600
1985
|
meta.append(element("i", "role-dot"), label);
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1986
|
+
const reasoning = element("section", "thinking-stream");
|
|
1987
|
+
reasoning.setAttribute("aria-label", "思考过程");
|
|
1988
|
+
const heading = element("header");
|
|
1989
|
+
const title = element("strong");
|
|
1990
|
+
title.textContent = "思考中";
|
|
1991
|
+
const hint = element("span");
|
|
1992
|
+
hint.textContent = "回复完成后自动隐藏";
|
|
1993
|
+
heading.append(title, hint);
|
|
1994
|
+
const thinkingBody = element("div", "thinking-stream-body");
|
|
1995
|
+
const thinkingText = document.createTextNode("");
|
|
1996
|
+
thinkingBody.append(thinkingText);
|
|
1997
|
+
reasoning.append(heading, thinkingBody);
|
|
1998
|
+
const body = element("div", "message-body markdown");
|
|
1999
|
+
const surface = createMarkdownSurface(body);
|
|
2000
|
+
const cursor = element("i", "stream-cursor");
|
|
2001
|
+
article.append(meta, reasoning, body);
|
|
2002
|
+
let labelText = "";
|
|
2003
|
+
return {
|
|
2004
|
+
node: article,
|
|
2005
|
+
update(value, thinking) {
|
|
2006
|
+
const nextLabel = value ? "pSpore · streaming" : "pSpore · thinking";
|
|
2007
|
+
if (labelText !== nextLabel) {
|
|
2008
|
+
label.textContent = nextLabel;
|
|
2009
|
+
labelText = nextLabel;
|
|
2010
|
+
}
|
|
2011
|
+
reasoning.hidden = thinking === "";
|
|
2012
|
+
// Written into the same Text node: replacing it would move the selection
|
|
2013
|
+
// out from under a reader holding one, and the value only ever grows.
|
|
2014
|
+
if (thinkingText.nodeValue !== thinking)
|
|
2015
|
+
thinkingText.nodeValue = thinking;
|
|
2016
|
+
body.hidden = value === "";
|
|
2017
|
+
if (value) {
|
|
2018
|
+
surface.update(value, cursor);
|
|
2019
|
+
}
|
|
2020
|
+
else {
|
|
2021
|
+
surface.update("");
|
|
2022
|
+
if (thinking)
|
|
2023
|
+
thinkingBody.append(cursor);
|
|
2024
|
+
else
|
|
2025
|
+
cursor.remove();
|
|
2026
|
+
}
|
|
2027
|
+
},
|
|
2028
|
+
};
|
|
620
2029
|
}
|
|
621
2030
|
function renderActivities(activities) {
|
|
622
|
-
elements.activityCount
|
|
2031
|
+
tweenMetric(elements.activityCount, activities.length, String);
|
|
623
2032
|
const cards = activities.slice(-12).reverse().map((activity) => {
|
|
624
2033
|
const card = element("article", "activity-card");
|
|
625
2034
|
card.dataset["status"] = activity.status;
|
|
@@ -627,14 +2036,14 @@ function renderActivities(activities) {
|
|
|
627
2036
|
const title = element("b");
|
|
628
2037
|
const state = element("span", `state-dot ${activity.status}`);
|
|
629
2038
|
state.setAttribute("aria-label", activity.status);
|
|
630
|
-
title.append(state, text(activity.label));
|
|
2039
|
+
title.append(state, text(activity.type.startsWith("tool.") && activity.toolName ? `${activity.toolName} · ${activity.label}` : activity.label));
|
|
631
2040
|
const time = element("time");
|
|
632
2041
|
time.textContent = clockTime(activity.ts);
|
|
633
2042
|
time.dateTime = activity.ts;
|
|
634
2043
|
header.append(title, time);
|
|
635
2044
|
card.append(header);
|
|
636
2045
|
const summary = element("p");
|
|
637
|
-
summary.textContent = `${activity.summary}${activity.resultSummary ? ` · ${activity.resultSummary}` : ""}${activity.sessionId ? ` ·
|
|
2046
|
+
summary.textContent = `${activity.summary}${activity.resultSummary ? ` · ${activity.resultSummary}` : ""}${activity.sessionId ? ` · 会话 ${shortId(activity.sessionId)}` : ""}`;
|
|
638
2047
|
card.append(summary);
|
|
639
2048
|
if (activity.detail) {
|
|
640
2049
|
const details = element("details");
|
|
@@ -648,13 +2057,137 @@ function renderActivities(activities) {
|
|
|
648
2057
|
return card;
|
|
649
2058
|
});
|
|
650
2059
|
elements.activityList.replaceChildren(...(cards.length ? cards : [emptyList("暂无活动事件")]));
|
|
2060
|
+
// The inspector re-renders on every frame; only animate when the set changed,
|
|
2061
|
+
// otherwise a streaming run would restart the stagger dozens of times a second.
|
|
2062
|
+
const signature = activities.slice(-12).map((activity) => `${activity.id}:${activity.status}`).join("|");
|
|
2063
|
+
if (signature !== lastActivitySignature) {
|
|
2064
|
+
if (lastActivitySignature && cards.length)
|
|
2065
|
+
revealListItems(cards, { distance: 6, maxStagger: 8 });
|
|
2066
|
+
lastActivitySignature = signature;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* What the agent is doing right now, so a run that produces no text for a while
|
|
2071
|
+
* is visibly working rather than indistinguishable from a hang.
|
|
2072
|
+
*
|
|
2073
|
+
* The phases are separated because they fail differently: a tool that never
|
|
2074
|
+
* returns is a stuck command, a model that never answers is a stalled request,
|
|
2075
|
+
* and an unanswered approval is waiting on the reader. Each gets its own mark
|
|
2076
|
+
* and its own elapsed clock.
|
|
2077
|
+
*/
|
|
2078
|
+
function renderRunActivity(snapshot) {
|
|
2079
|
+
const phase = currentRunPhase(snapshot);
|
|
2080
|
+
if (!phase) {
|
|
2081
|
+
activityPhase = undefined;
|
|
2082
|
+
stopActivityTicker();
|
|
2083
|
+
elements.runActivity.hidden = true;
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (activityPhase?.key !== phase.key)
|
|
2087
|
+
activityPhase = { key: phase.key, since: Date.now() };
|
|
2088
|
+
elements.runActivity.hidden = false;
|
|
2089
|
+
elements.runActivity.dataset["phase"] = phase.kind;
|
|
2090
|
+
elements.runActivityMark.dataset["phase"] = phase.kind;
|
|
2091
|
+
elements.runActivityLabel.textContent = phase.label;
|
|
2092
|
+
const seconds = Math.floor((Date.now() - (activityPhase?.since ?? Date.now())) / 1000);
|
|
2093
|
+
// Silence for a second or two is normal; a count that only appears after that
|
|
2094
|
+
// keeps the line calm while still answering "how long" when it matters.
|
|
2095
|
+
elements.runActivityElapsed.textContent = seconds >= 2 ? `已 ${formatElapsed(seconds)}` : "";
|
|
2096
|
+
startActivityTicker();
|
|
2097
|
+
}
|
|
2098
|
+
function currentRunPhase(snapshot) {
|
|
2099
|
+
const sessionId = snapshot.selectedSessionId;
|
|
2100
|
+
// An approval blocks the run until the reader acts, so it outranks everything
|
|
2101
|
+
// else: nothing will move until it is answered.
|
|
2102
|
+
const approval = Object.values(snapshot.kernel.approvals)
|
|
2103
|
+
.find((request) => request.sessionId === sessionId);
|
|
2104
|
+
if (approval) {
|
|
2105
|
+
return { key: `approval:${approval.id}`, kind: "approval", label: `等待你批准 ${approval.capability}` };
|
|
2106
|
+
}
|
|
2107
|
+
const session = snapshot.kernel.sessions[sessionId];
|
|
2108
|
+
const child = selectedAgent(snapshot);
|
|
2109
|
+
const running = (session?.status ?? child?.status) === "running";
|
|
2110
|
+
if (!running)
|
|
2111
|
+
return undefined;
|
|
2112
|
+
const tool = snapshot.activities
|
|
2113
|
+
.filter((activity) => activity.status === "running" && (activity.sessionId ?? sessionId) === sessionId)
|
|
2114
|
+
.at(-1);
|
|
2115
|
+
if (tool) {
|
|
2116
|
+
const label = tool.toolName === "article" ? "撰写文稿" : tool.toolName === "compaction" ? "正在压缩上下文,完成后继续" : `执行 ${tool.toolName ?? tool.label}`;
|
|
2117
|
+
return { key: `tool:${tool.id}`, kind: "tool", label };
|
|
2118
|
+
}
|
|
2119
|
+
if (articles.isWriting())
|
|
2120
|
+
return { key: `article:${articles.writingKey()}`, kind: "tool", label: "撰写文稿" };
|
|
2121
|
+
if (snapshot.streamingText[sessionId])
|
|
2122
|
+
return { key: `stream:${sessionId}`, kind: "streaming", label: "生成回复中" };
|
|
2123
|
+
if (snapshot.streamingThinking[sessionId])
|
|
2124
|
+
return { key: `think:${sessionId}`, kind: "thinking", label: "思考中" };
|
|
2125
|
+
return { key: `wait:${sessionId}`, kind: "waiting", label: "等待模型响应" };
|
|
2126
|
+
}
|
|
2127
|
+
/** The elapsed count has to advance without new frames, so it drives itself. */
|
|
2128
|
+
function startActivityTicker() {
|
|
2129
|
+
if (activityTicker !== undefined)
|
|
2130
|
+
return;
|
|
2131
|
+
activityTicker = window.setInterval(() => {
|
|
2132
|
+
if (elements.runActivity.hidden || !activityPhase) {
|
|
2133
|
+
stopActivityTicker();
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const seconds = Math.floor((Date.now() - activityPhase.since) / 1000);
|
|
2137
|
+
elements.runActivityElapsed.textContent = seconds >= 2 ? `已 ${formatElapsed(seconds)}` : "";
|
|
2138
|
+
}, 1_000);
|
|
2139
|
+
}
|
|
2140
|
+
function stopActivityTicker() {
|
|
2141
|
+
if (activityTicker === undefined)
|
|
2142
|
+
return;
|
|
2143
|
+
window.clearInterval(activityTicker);
|
|
2144
|
+
activityTicker = undefined;
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Continuous indexing status. The daemon owns the work; this is a read-only
|
|
2148
|
+
* view so the user can tell whether a search is running against a current index.
|
|
2149
|
+
*/
|
|
2150
|
+
function renderIndexing(snapshot) {
|
|
2151
|
+
const view = presentIndexing(snapshot.kernel.indexing, Date.now());
|
|
2152
|
+
elements.indexingPanel.dataset["phase"] = view.phase;
|
|
2153
|
+
elements.indexingPanel.dataset["progress"] = view.percent === undefined ? "indeterminate" : "determinate";
|
|
2154
|
+
elements.indexingPhase.textContent = view.phaseLabel;
|
|
2155
|
+
const bar = elements.indexingBarFill.parentElement;
|
|
2156
|
+
if (view.percent === undefined) {
|
|
2157
|
+
// An indeterminate bar has no value; the stylesheet animates the fill instead.
|
|
2158
|
+
elements.indexingBarFill.style.width = "";
|
|
2159
|
+
bar?.removeAttribute("aria-valuenow");
|
|
2160
|
+
}
|
|
2161
|
+
else {
|
|
2162
|
+
elements.indexingBarFill.style.width = `${view.percent}%`;
|
|
2163
|
+
bar?.setAttribute("aria-valuenow", String(view.percent));
|
|
2164
|
+
}
|
|
2165
|
+
elements.indexingCounts.textContent = view.counts;
|
|
2166
|
+
elements.indexingCurrent.textContent = view.current;
|
|
2167
|
+
elements.indexingNote.textContent = view.note;
|
|
2168
|
+
if (view.ticking)
|
|
2169
|
+
startIndexingTicker();
|
|
2170
|
+
else
|
|
2171
|
+
stopIndexingTicker();
|
|
2172
|
+
}
|
|
2173
|
+
/** A walk that reports nothing still has to show the clock moving. */
|
|
2174
|
+
function startIndexingTicker() {
|
|
2175
|
+
if (indexingTicker !== undefined)
|
|
2176
|
+
return;
|
|
2177
|
+
indexingTicker = window.setInterval(() => renderIndexing(store.snapshot()), 1_000);
|
|
2178
|
+
}
|
|
2179
|
+
function stopIndexingTicker() {
|
|
2180
|
+
if (indexingTicker === undefined)
|
|
2181
|
+
return;
|
|
2182
|
+
window.clearInterval(indexingTicker);
|
|
2183
|
+
indexingTicker = undefined;
|
|
651
2184
|
}
|
|
652
2185
|
function renderJobs(snapshot) {
|
|
653
|
-
const connected = snapshot.
|
|
2186
|
+
const connected = snapshot.stableConnection.phase === "connected";
|
|
654
2187
|
const jobNodes = jobs.slice(0, 4).map((job) => {
|
|
655
2188
|
const item = denseItem(job.id, job.enabled ? `${job.schedule.cron} · ${job.schedule.timezone}` : "disabled", job.enabled ? "ready" : "disabled");
|
|
656
2189
|
const actions = element("div", "item-actions");
|
|
657
|
-
const run = actionButton("立即运行", "ghost-button", () => void perform(
|
|
2190
|
+
const run = actionButton("立即运行", "ghost-button", () => void perform(`运行 ${job.id}`, { action: "job.run", jobId: job.id }));
|
|
658
2191
|
run.disabled = !connected || !job.enabled;
|
|
659
2192
|
actions.append(run);
|
|
660
2193
|
item.append(actions);
|
|
@@ -668,7 +2201,7 @@ function renderJobs(snapshot) {
|
|
|
668
2201
|
const item = denseItem(run.jobId ?? shortId(run.id), `${run.status} · ${clockTime(run.receivedAt)}`, run.status);
|
|
669
2202
|
if (run.status === "queued" || run.status === "running") {
|
|
670
2203
|
const actions = element("div", "item-actions");
|
|
671
|
-
const cancel = actionButton("取消运行", "danger-ghost-button", () => void perform(
|
|
2204
|
+
const cancel = actionButton("取消运行", "danger-ghost-button", () => void perform(`取消运行 ${run.id}`, { action: "run.cancel", runId: run.id }));
|
|
672
2205
|
cancel.disabled = !connected;
|
|
673
2206
|
actions.append(cancel);
|
|
674
2207
|
item.append(actions);
|
|
@@ -677,14 +2210,61 @@ function renderJobs(snapshot) {
|
|
|
677
2210
|
});
|
|
678
2211
|
elements.runList.replaceChildren(...(runNodes.length ? runNodes : [emptyList("暂无运行记录")]));
|
|
679
2212
|
}
|
|
2213
|
+
function isBuiltinTool(value) {
|
|
2214
|
+
return typeof value === "object" && value !== null && typeof value.name === "string";
|
|
2215
|
+
}
|
|
680
2216
|
function renderIntegrations(snapshot) {
|
|
681
|
-
const connected = snapshot.
|
|
2217
|
+
const connected = snapshot.stableConnection.phase === "connected";
|
|
682
2218
|
const nodes = [];
|
|
2219
|
+
// The fixed tools come first: they are what the agent can always do, and
|
|
2220
|
+
// without them the panel only ever answered "what has been added".
|
|
2221
|
+
if (builtinTools.length > 0) {
|
|
2222
|
+
const tools = element("div", "tool-chips");
|
|
2223
|
+
tools.setAttribute("role", "list");
|
|
2224
|
+
for (const tool of builtinTools) {
|
|
2225
|
+
const chip = element("span", "tool-chip");
|
|
2226
|
+
chip.setAttribute("role", "listitem");
|
|
2227
|
+
chip.textContent = tool.name;
|
|
2228
|
+
if (tool.description)
|
|
2229
|
+
chip.title = `${tool.label ?? tool.name} — ${tool.description}`;
|
|
2230
|
+
tools.append(chip);
|
|
2231
|
+
}
|
|
2232
|
+
const item = denseItem(`内置工具 · ${builtinTools.length}`, "每个会话都具备", "ready");
|
|
2233
|
+
item.append(tools);
|
|
2234
|
+
nodes.push(item);
|
|
2235
|
+
}
|
|
2236
|
+
// Skills next, from every source a session loads them from — not only the
|
|
2237
|
+
// trusted installs below — so a skill the user dropped into the workspace is
|
|
2238
|
+
// either visible here or listed with the reason it was not loaded.
|
|
2239
|
+
const activeSkills = skillCatalog.filter((skill) => skill.status === "active");
|
|
2240
|
+
if (activeSkills.length > 0) {
|
|
2241
|
+
const chips = element("div", "tool-chips skill-chips");
|
|
2242
|
+
chips.setAttribute("role", "list");
|
|
2243
|
+
for (const skill of activeSkills) {
|
|
2244
|
+
const chip = element("span", "tool-chip");
|
|
2245
|
+
chip.setAttribute("role", "listitem");
|
|
2246
|
+
chip.textContent = skill.name;
|
|
2247
|
+
chip.title = [`${skill.name} · ${SKILL_SOURCE_LABELS[skill.source]}`, skill.description, skill.path, skill.message]
|
|
2248
|
+
.filter(Boolean)
|
|
2249
|
+
.join("\n");
|
|
2250
|
+
chips.append(chip);
|
|
2251
|
+
}
|
|
2252
|
+
const item = denseItem(`Skills · ${activeSkills.length}`, "新会话可用", "ready");
|
|
2253
|
+
item.append(chips);
|
|
2254
|
+
nodes.push(item);
|
|
2255
|
+
}
|
|
2256
|
+
for (const skill of skillCatalog.filter((candidate) => candidate.status !== "active")) {
|
|
2257
|
+
const item = denseItem(`未加载 Skill · ${skill.name}`, `${SKILL_SOURCE_LABELS[skill.source]} · ${skill.status === "shadowed" ? "同名被覆盖" : "已跳过"}`, "degraded");
|
|
2258
|
+
const reason = element("p");
|
|
2259
|
+
reason.textContent = skill.message ? `${skill.message}(${skill.path})` : skill.path;
|
|
2260
|
+
item.append(reason);
|
|
2261
|
+
nodes.push(item);
|
|
2262
|
+
}
|
|
683
2263
|
for (const health of Object.values(snapshot.kernel.mcpServers)) {
|
|
684
2264
|
const item = denseItem(`MCP · ${health.serverId}`, `${health.status} · ${health.toolCount} tools`, health.status);
|
|
685
2265
|
const actions = element("div", "item-actions");
|
|
686
|
-
const discover = actionButton("发现工具", "ghost-button", () => void perform(
|
|
687
|
-
const ping = actionButton("Ping", "ghost-button", () => void perform(`
|
|
2266
|
+
const discover = actionButton("发现工具", "ghost-button", () => void perform(`发现 ${health.serverId} 工具`, { action: "mcp.discover", serverId: health.serverId }));
|
|
2267
|
+
const ping = actionButton("Ping", "ghost-button", () => void perform(`Ping ${health.serverId}`, { action: "mcp.ping", serverId: health.serverId }));
|
|
688
2268
|
discover.disabled = !connected || health.status === "disabled" || health.status === "untrusted";
|
|
689
2269
|
ping.disabled = discover.disabled;
|
|
690
2270
|
actions.append(discover, ping);
|
|
@@ -697,7 +2277,7 @@ function renderIntegrations(snapshot) {
|
|
|
697
2277
|
const catalog = catalogByKey.get(`${health.kind}:${health.name}`);
|
|
698
2278
|
if (catalog?.record.trusted) {
|
|
699
2279
|
const actions = element("div", "item-actions");
|
|
700
|
-
const disable = actionButton("撤销信任", "danger-ghost-button", () => void perform(
|
|
2280
|
+
const disable = actionButton("撤销信任", "danger-ghost-button", () => void perform(`撤销 ${health.name} 信任`, {
|
|
701
2281
|
action: "resources.disable",
|
|
702
2282
|
kind: health.kind,
|
|
703
2283
|
name: health.name,
|
|
@@ -725,15 +2305,26 @@ function renderApproval(snapshot) {
|
|
|
725
2305
|
const request = Object.values(snapshot.kernel.approvals).sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))[0];
|
|
726
2306
|
if (!request) {
|
|
727
2307
|
const previousApproval = activeApprovalId;
|
|
728
|
-
elements.approvalOverlay.hidden = true;
|
|
729
|
-
updateAppInert();
|
|
730
2308
|
activeApprovalId = undefined;
|
|
731
|
-
if (
|
|
2309
|
+
if (elements.approvalOverlay.hidden)
|
|
2310
|
+
return;
|
|
2311
|
+
updateAppInert();
|
|
2312
|
+
const finishHide = () => {
|
|
2313
|
+
if (activeApprovalId !== undefined)
|
|
2314
|
+
return;
|
|
2315
|
+
elements.approvalOverlay.hidden = true;
|
|
2316
|
+
updateAppInert();
|
|
732
2317
|
requestAnimationFrame(() => elements.composerInput.focus());
|
|
2318
|
+
};
|
|
2319
|
+
if (previousApproval)
|
|
2320
|
+
hideOverlayMotion(elements.approvalOverlay, elements.approvalDialog, finishHide);
|
|
2321
|
+
else
|
|
2322
|
+
elements.approvalOverlay.hidden = true;
|
|
733
2323
|
return;
|
|
734
2324
|
}
|
|
735
2325
|
if (settingsOpen && !savingSettings)
|
|
736
2326
|
closeSettings(false);
|
|
2327
|
+
const firstPaint = elements.approvalOverlay.hidden || activeApprovalId !== request.id;
|
|
737
2328
|
elements.approvalOverlay.hidden = false;
|
|
738
2329
|
updateAppInert();
|
|
739
2330
|
elements.approvalTitle.textContent = request.display.summary;
|
|
@@ -743,21 +2334,26 @@ function renderApproval(snapshot) {
|
|
|
743
2334
|
elements.approvalReasons.textContent = request.riskReasons.join(";") || "策略要求确认";
|
|
744
2335
|
elements.approvalCommand.hidden = !request.display.command;
|
|
745
2336
|
elements.approvalCommand.textContent = request.display.command ?? "";
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
for (const scope of request.allowedScopes) {
|
|
750
|
-
const label = scope === "once" ? "仅此一次" : scope === "session" ? "本会话允许" : "此任务允许";
|
|
751
|
-
actions.push(approvalButton(label, scope === "once" ? "primary-button" : "secondary-button", request, true, scope));
|
|
752
|
-
}
|
|
753
|
-
elements.approvalActions.replaceChildren(...actions);
|
|
2337
|
+
// The buttons are rebuilt only for a different request. Replacing them on every
|
|
2338
|
+
// render destroys whichever one holds focus, which silently moved focus back to
|
|
2339
|
+
// the document whenever anything else re-rendered while the modal was open.
|
|
754
2340
|
if (activeApprovalId !== request.id) {
|
|
2341
|
+
const actions = [];
|
|
2342
|
+
const denialScope = request.allowedScopes[0] ?? "once";
|
|
2343
|
+
actions.push(approvalButton("拒绝", "danger-ghost-button", request, false, denialScope));
|
|
2344
|
+
for (const scope of request.allowedScopes) {
|
|
2345
|
+
const label = scope === "once" ? "仅此一次" : scope === "session" ? "本会话允许" : "此任务允许";
|
|
2346
|
+
actions.push(approvalButton(label, scope === "once" ? "primary-button" : "secondary-button", request, true, scope));
|
|
2347
|
+
}
|
|
2348
|
+
elements.approvalActions.replaceChildren(...actions);
|
|
755
2349
|
activeApprovalId = request.id;
|
|
2350
|
+
if (firstPaint)
|
|
2351
|
+
showOverlayMotion(elements.approvalOverlay, elements.approvalDialog);
|
|
756
2352
|
requestAnimationFrame(() => actions[0]?.focus());
|
|
757
2353
|
}
|
|
758
2354
|
}
|
|
759
2355
|
function approvalButton(label, className, request, approved, scope) {
|
|
760
|
-
return actionButton(label, className, () => void perform(`${approved ? "
|
|
2356
|
+
return actionButton(label, className, () => void perform(`${approved ? "批准" : "拒绝"} ${request.id}`, {
|
|
761
2357
|
action: "approval.resolve",
|
|
762
2358
|
approvalId: request.id,
|
|
763
2359
|
approved,
|
|
@@ -776,16 +2372,16 @@ function renderInspection() {
|
|
|
776
2372
|
const digest = element("code");
|
|
777
2373
|
digest.textContent = result.digest;
|
|
778
2374
|
const summary = element("p");
|
|
779
|
-
summary.textContent = `${result.files.length}
|
|
2375
|
+
summary.textContent = `${result.files.length} 个文件 · ${formatBytes(result.totalBytes)}${result.diagnostics.length ? ` · ${result.diagnostics.join(";")}` : ""}`;
|
|
780
2376
|
const install = actionButton(result.installable ? "信任并安装精确内容" : "不可安装", "secondary-button", () => {
|
|
781
2377
|
const stagingName = elements.stagingName.value.trim();
|
|
782
2378
|
if (stagingName)
|
|
783
|
-
void perform(
|
|
2379
|
+
void perform(`安装 ${result.name}`, { action: "resources.install", stagingName, expectedDigest: result.digest }, async () => {
|
|
784
2380
|
inspection = undefined;
|
|
785
2381
|
await refreshSideData();
|
|
786
2382
|
});
|
|
787
2383
|
});
|
|
788
|
-
install.disabled = !result.installable || store.snapshot().
|
|
2384
|
+
install.disabled = !result.installable || store.snapshot().stableConnection.phase !== "connected";
|
|
789
2385
|
elements.inspectionResult.replaceChildren(title, digest, summary, install);
|
|
790
2386
|
}
|
|
791
2387
|
function renderVersions() {
|
|
@@ -836,6 +2432,33 @@ function byId(id) {
|
|
|
836
2432
|
throw new Error(`Missing desktop element #${id}`);
|
|
837
2433
|
return value;
|
|
838
2434
|
}
|
|
2435
|
+
function bindSecretField(input, reveal, clear) {
|
|
2436
|
+
reveal.addEventListener("click", () => {
|
|
2437
|
+
const revealing = input.type === "password";
|
|
2438
|
+
input.type = revealing ? "text" : "password";
|
|
2439
|
+
reveal.textContent = revealing ? "隐藏" : "显示";
|
|
2440
|
+
});
|
|
2441
|
+
input.addEventListener("input", () => {
|
|
2442
|
+
if (input.value)
|
|
2443
|
+
clear.checked = false;
|
|
2444
|
+
});
|
|
2445
|
+
clear.addEventListener("change", () => {
|
|
2446
|
+
if (clear.checked)
|
|
2447
|
+
input.value = "";
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
function resetSecretField(input, reveal, clear, configured, label) {
|
|
2451
|
+
input.value = "";
|
|
2452
|
+
input.type = "password";
|
|
2453
|
+
input.placeholder = configured ? "已配置;留空将保留" : `输入 ${label}`;
|
|
2454
|
+
reveal.textContent = "显示";
|
|
2455
|
+
clear.checked = false;
|
|
2456
|
+
}
|
|
2457
|
+
function settingsStatus(settings) {
|
|
2458
|
+
const key = settings.hasApiKey ? "已检测到私有 Key" : "尚未保存 API Key";
|
|
2459
|
+
const cookie = settings.hasCookie ? "已检测到 Cookie" : "尚未保存 Cookie";
|
|
2460
|
+
return `${key};${cookie}。留空会保留当前值。`;
|
|
2461
|
+
}
|
|
839
2462
|
function missingElement(selector) {
|
|
840
2463
|
throw new Error(`Missing desktop element ${selector}`);
|
|
841
2464
|
}
|
|
@@ -856,16 +2479,17 @@ function initialTheme() {
|
|
|
856
2479
|
return stored;
|
|
857
2480
|
}
|
|
858
2481
|
catch {
|
|
859
|
-
// Fall through to the
|
|
2482
|
+
// Fall through to the daylight default.
|
|
860
2483
|
}
|
|
861
|
-
|
|
2484
|
+
// The product default is the daylight (white & cobalt) theme.
|
|
2485
|
+
return "light";
|
|
862
2486
|
}
|
|
863
2487
|
function applyTheme(theme) {
|
|
864
2488
|
document.documentElement.dataset["theme"] = theme;
|
|
865
2489
|
const nextLabel = theme === "dark" ? "切换到浅色模式" : "切换到深色模式";
|
|
866
2490
|
elements.themeToggle.setAttribute("aria-label", nextLabel);
|
|
867
2491
|
elements.themeToggle.title = nextLabel;
|
|
868
|
-
elements.themeColor.content = theme === "dark" ? "#
|
|
2492
|
+
elements.themeColor.content = theme === "dark" ? "#0c121b" : "#f4f7fb";
|
|
869
2493
|
}
|
|
870
2494
|
function friendlyConnectionMessage(message, phase) {
|
|
871
2495
|
if (message?.includes("ENOENT") || message?.includes("daemon.sock")) {
|
|
@@ -884,9 +2508,22 @@ function isResourceEntry(value) {
|
|
|
884
2508
|
return false;
|
|
885
2509
|
return typeof value["record"]["name"] === "string" && typeof value["record"]["digest"] === "string";
|
|
886
2510
|
}
|
|
2511
|
+
function isSkillCatalogEntry(value) {
|
|
2512
|
+
return isRecord(value)
|
|
2513
|
+
&& typeof value["name"] === "string"
|
|
2514
|
+
&& typeof value["path"] === "string"
|
|
2515
|
+
&& (value["source"] === "installed" || value["source"] === "bundled" || value["source"] === "workspace")
|
|
2516
|
+
&& (value["status"] === "active" || value["status"] === "skipped" || value["status"] === "shadowed");
|
|
2517
|
+
}
|
|
887
2518
|
function isInspection(value) {
|
|
888
2519
|
return isRecord(value) && value["version"] === 1 && typeof value["name"] === "string" && typeof value["digest"] === "string" && Array.isArray(value["files"]);
|
|
889
2520
|
}
|
|
2521
|
+
function isSessionView(value) {
|
|
2522
|
+
return isRecord(value)
|
|
2523
|
+
&& typeof value["sessionId"] === "string"
|
|
2524
|
+
&& typeof value["upstreamSessionId"] === "string"
|
|
2525
|
+
&& Array.isArray(value["messages"]);
|
|
2526
|
+
}
|
|
890
2527
|
function isRecord(value) {
|
|
891
2528
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
892
2529
|
}
|