diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2749cc8..040e717 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,8 @@ concurrency: jobs: validate: name: Typecheck, smoke, and build - runs-on: ubuntu-24.04 + # Profile migration uses macOS shlock; validate on the supported desktop OS. + runs-on: macos-15 timeout-minutes: 20 steps: @@ -26,6 +27,14 @@ jobs: with: persist-credentials: false + - name: Check out pinned Harness + uses: actions/checkout@v6 + with: + repository: deepseek-ai/deepseek-harness + ref: 639ed015397290b3745d163aafe02ffee4aa3f84 + path: upstream/deepseek-harness + persist-credentials: false + - name: Install pnpm uses: pnpm/action-setup@v6 @@ -41,9 +50,32 @@ jobs: - name: Typecheck run: pnpm typecheck - # Development snapshots use bounded smoke coverage; full regression is separate. - - name: Smoke + - name: Validate pinned adapter contracts + run: node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs + + - name: Install Harness dependencies + run: pnpm --dir upstream/deepseek-harness install --frozen-lockfile + + - name: Build Harness and DeepViewer plugins + run: node apps/deepviewer-desktop/scripts/build-native-desktop.mjs --core-only + + - name: Bounded smoke coverage run: pnpm desktop:smoke - - name: Build - run: pnpm desktop:build + - name: Native Synapse integration and UI regression smoke + run: | + pnpm --filter @deepviewer/desktop smoke:synapse + pnpm --filter @deepviewer/desktop smoke:ui-regression + + - name: Fresh upstream contracts and policy fixtures + run: | + node apps/deepviewer-desktop/scripts/smoke-native-contract-source.mjs + node apps/deepviewer-desktop/scripts/smoke-native-header-placement.mjs + pnpm --filter @deepviewer/desktop smoke:contracts + node --test --test-timeout=15000 apps/dsh-plugin-model-capabilities/test/*.test.mjs + node --experimental-strip-types --test apps/deepviewer-adapter/tests/workflows.test.mjs + + - name: Authenticated Host and model plugin smoke + run: | + node apps/deepviewer-desktop/scripts/smoke-model-capabilities-host.mjs + node apps/dsh-plugin-model-capabilities/scripts/smoke-runtime.mjs diff --git a/.gitignore b/.gitignore index cd9419d..a592c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,14 @@ out/ # Local checkout of the official upstream source. The analyzed revision and # refresh procedure are recorded in upstream/README.md. upstream/deepseek-harness/ + +# Generated official desktop carrier and adapter bundles +apps/deepviewer-desktop/lib/ +apps/deepviewer-desktop/renderer/ +apps/deepviewer-adapter/lib/ + +# Generated official desktop launcher shims +/apps/deepviewer-desktop/scripts/node-bin/ + +# Local independent worktrees and inspection checkouts +.worktrees/ diff --git a/README.md b/README.md index 7db3f86..74b75be 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,15 @@ Latest release CI macOS Apple Silicon - Electron 43 + Electron 44 License: MIT Discuss on GitHub

- Download 0.3.3 (ARM64) + 0.5.0 release candidate (ARM64) · - What's new + What's new · Privacy & data · @@ -33,8 +33,10 @@ DeepViewer is an independent, open-source desktop agent workspace built on [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). It bundles -the pinned runtime into a macOS application and adds Work and Chat spaces, -file delivery, a sidebar workbench, and desktop controls. +the pinned runtime into a macOS application with the native Electron +DesktopHost, Work and Chat spaces, file delivery, a sidebar workbench, and a +session map. The 0.5.0 source derives from Scoder 0.7.0 Build 43, restored to +DeepViewer's identity and independent data directories.

DeepViewer 0.3.3 delivering a generated image with an attachment card and sidebar preview @@ -44,93 +46,100 @@ file delivery, a sidebar workbench, and desktop controls. > DeepViewer is a community project. It is not affiliated with or endorsed by DeepSeek. > [!IMPORTANT] -> The current release is **DeepViewer 0.3.3 / Build 75**, based on DeepSeek Harness `0.1.5-rc.2` and available for **Apple Silicon arm64 only**. The DMG is Developer ID signed and Apple-notarized. Final visual acceptance of migrated sessions, settings, and attachments remains with the maintainer. +> **DeepViewer 0.5.0 is being prepared for public release**, based on DeepSeek Harness `0.2.0-rc.2`, for **Apple Silicon arm64 only**. A freshly built Developer ID signed and Apple-notarized DMG is the release target; its final build number, signing/notarization results, checksums, and download link are pending confirmation. Visual acceptance, real-account flows, and migrated sessions/settings/attachments remain separate manual checks. > [!TIP] -> The [release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) includes a SHA-256 checksum. Installed and development data now use separate, version-independent directories; back up existing data before a major upgrade. The previous public [0.2.5 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) remains available. +> Installed and development data use separate, version-independent directories; back up existing data before a major upgrade. The previous public [0.3.3 / Build 75 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3), with its SHA-256 checksum, and [0.2.5 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) remain available. -## What changed since the previous public release (0.2.5) +> [!NOTE] +> All screenshots in this README are historical **0.3.3 / Build 75** references. They have not been replaced or used as evidence of the 0.5.0 interface or visual acceptance. + +## What changed since the previous public release (0.3.3) -| Area | Changes in 0.3.3 | +| Area | Changes in the 0.5.0 candidate | | --- | --- | -| Work and Chat | Separate workspace-backed Work sessions from workspace-free Chat history. Create projects from the sidebar, search Chat history, and recover session mode and titles after a restart without opening every conversation. | -| Files and previews | Deliver generated images and other files as attachment cards. Materialize external or extensionless outputs inside the session workspace with an appropriate extension so the card and sidebar preview refer to the same file. | -| Sidebar workbench | Integrate the open-source Better Sidebar workbench for files, editor, terminal, Git and browser panels. Desktop adaptations preserve window chrome, session-relative paths, navigation, and authentication checks. | -| Models | Add a first-party reasoning and multimodal capability editor with bounded model scanning. Provider support is checked per model; a successful scan does not guarantee every provider feature works. | -| Progress and appearance | Group related context and operations into expandable summaries, align their text and multimodal status with the conversation font-size setting, add clearer hover states, image-generation shimmer, and a 12 × 12 px Drive activity indicator. Settings layout and system appearance follow the desktop shell. | -| Networking | Use the system proxy/PAC configuration for the bundled runtime, preserve explicit proxy settings, show clearer connection errors, and request scoped permission before accessing private-network addresses. | -| Upgrade continuity | Installed and development editions use separate, fixed data directories. First launch imports older same-edition sessions and attachments with backups; later app and Harness versions reuse the fixed directory. Cold session lists restore Work/Chat classification from persisted events. | +| Native desktop | Move from the previous custom shell and DSH 0.1.5 overlays to the official Electron/DesktopHost and pinned DSH `0.2.0-rc.2`, using Scoder `0.7.0 / Build 43` as the source baseline. | +| Work, Chat and session map | Retain workspace-backed Work and workspace-free Chat, and add the built-in Synapse session map for exploring session context. | +| Sidebar workbench | Harness native panels take over the standalone Better Sidebar activation. DeepViewer retains file/editor/Git workflows, session-relative paths, desktop browser integration, and permission checks. | +| Built-in modules | Bundle subscriptions `0.3.1` and model-capabilities `1.0.2` as application modules. Model-capabilities replaces the old reasoning plugin and carries over its supported settings. | +| Files and appearance | Keep attachment delivery and workspace previews, restore DeepViewer branding throughout the native shell, and follow desktop/system appearance. | +| Development stability | Ignore filesystem synchronization that only changes artifact metadata, preserving the mounted renderer. Real code changes and completed-build timestamps still trigger hot reload. | +| Upgrade continuity | Preserve separate stable/development data roots, snapshot the old DeepViewer kernel data before opening the new stores, and retain migration/rollback backups. Scoder and Saidex data are excluded from migration candidates. | +| Networking | Retain system proxy/PAC support, explicit proxy settings, scoped loopback/RPC authentication, and private-network permission boundaries. | -The removed Taskboard integration is **not** included. Some provider, sidebar, and migrated-data flows still need real-account and manual UI acceptance; see the [release record](docs/sdd/releases/v0.3.3.md) for the verification boundary. +The standalone Better Sidebar and reasoning plugins are retired from activation; their replacement is part of this migration. Taskboard remains removed, and the historical preview plugin remains disabled. See the [0.5.0 specification](docs/sdd/specs/DV-0033-scoder-native-rebase/spec.md) for the current verification boundary and the [0.3.3 release record](docs/sdd/releases/v0.3.3.md) for historical evidence. ### Work, Chat, and process disclosure Work groups sessions by project. Chat keeps conversations independent of a workspace. Both modes restore their history on startup, and related context, reasoning, searches, and operations appear in expandable summaries. -![A Work project and its conversation history](Resources/screenshots/work-session.png) +![Historical 0.3.3: a Work project and its conversation history](Resources/screenshots/work-session.png) -![Workspace-free Chat with expanded process disclosure](Resources/screenshots/chat-and-disclosure.png) +![Historical 0.3.3: workspace-free Chat with expanded process disclosure](Resources/screenshots/chat-and-disclosure.png) ### Files and sidebar workbench The sidebar can inspect workspace files, preview generated HTML, and browse pages alongside the conversation. File paths, panel navigation, and browser access are connected to the active desktop session. -![A web page opened beside a Work conversation](Resources/screenshots/browser-workbench.png) +![Historical 0.3.3: a web page opened beside a Work conversation](Resources/screenshots/browser-workbench.png) -![A generated HTML page in the sidebar preview](Resources/screenshots/html-preview.png) +![Historical 0.3.3: a generated HTML page in the sidebar preview](Resources/screenshots/html-preview.png) ### Generated images and attachments Generated files are delivered as attachment cards with usable names and extensions. The same workspace copy opens in the sidebar preview, including images first produced outside the workspace. -![Generated image attachment and sidebar preview](Resources/screenshots/image-delivery.png) +![Historical 0.3.3: generated image attachment and sidebar preview](Resources/screenshots/image-delivery.png) -![Image generation result shown with its process and sidebar preview](Resources/screenshots/image-generation.png) +![Historical 0.3.3: image generation result with its process and sidebar preview](Resources/screenshots/image-generation.png) ### Appearance and version identity Settings follow the desktop appearance, and the About page shows the app build and bundled Harness version. -![About DeepViewer showing version 0.3.3 Build 75 and Harness 0.1.5-rc.2](Resources/screenshots/about-version.png) +![Historical 0.3.3: About showing Build 75 and Harness 0.1.5-rc.2](Resources/screenshots/about-version.png) ## Install and data -1. Download `DeepViewer-0.3.3-macos-arm64.dmg` and check it against `SHA256SUMS.txt` in the [release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3). +1. Once 0.5.0 is published, download the expected `DeepViewer-0.5.0-arm64.dmg` and verify it against `SHA256SUMS.txt` from [GitHub Releases](https://github.com/Duoasa/DeepViewer/releases). The candidate's final assets and download link are pending confirmation. 2. Open the DMG and copy `DeepViewer.app` to Applications. 3. Open the app and configure a provider or subscription. No global Node.js or Harness installation is needed. DeepViewer binds its local service to a random loopback port. Installed app data lives in `~/Library/Application Support/DeepViewer`; development app data lives in `~/Library/Application Support/DeepViewer Dev`. Replacing the application does not replace these directories. Legacy data is copied and verified before first-use cutover; migration backups are retained. Keep your own backup before a major upgrade. -The release package is built from a clean allowlist of application code, pinned runtime packages, assets, and licenses. It does **not** contain the maintainer's sessions, workspaces, settings, logs, account credentials, home-directory paths, or development handoff files. The packaged app is audited before signing and publication. +The packaging workflow materializes an allowlist of application code, pinned runtime packages, assets, and licenses. Release acceptance requires auditing the final package for developer-machine paths, private data, and escaping links, as well as verifying its signature, notarization, and checksums. The 0.5.0 candidate's final package evidence is pending. ## Open-source foundations and DeepViewer modifications | Component | Source and license | What DeepViewer changes | | --- | --- | --- | -| Core agent | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), MIT, pinned to `fb2c4b9e698e30edb738bca4cf0618587db7d203` (`0.1.5-rc.2`) | Desktop integration and a [reproducible source patch](upstream/snapshots/v0.3.3/README.md) for Work/Chat, session recovery, file delivery, progressive disclosure, themes, and network bridging. The agent loop remains upstream-owned. | -| Subscriptions and image tools | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions), MIT, `0.3.1` | DSH 0.1.5 compatibility adapter, scoped RPC/loopback checks, remaining-usage display, and image-generation status styling. | -| Sidebar workbench | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar), MIT, `0.19.1` | macOS chrome and settings adaptation, native panel routing, session-relative paths, and desktop browser/authentication integration. | -| Model capability editor | [DeepViewer first-party plugin](apps/dsh-plugin-reasoning), MIT, `0.1.0` | Provider/model scanning and manual reasoning and multimodal capability configuration. | +| Core agent and native shell | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), MIT, pinned to `639ed015397290b3745d163aafe02ffee4aa3f84` (`0.2.0-rc.2`) | Official Electron/DesktopHost with the [DeepViewer adapter](apps/deepviewer-adapter) and checked compatibility contracts. The agent loop remains upstream-owned. | +| Desktop source baseline | [Duoasa/scoder](https://github.com/Duoasa/scoder), `0.7.0 / Build 43`, pinned to `e277adc6ce512655095e6f65c63bdc72d24bae95`; original notices retained | Restore DeepViewer branding, package/update identities and data isolation, retain native features, and fix metadata-driven renderer replacement. Scoder's release history and user data are not imported. | +| Subscriptions and image tools | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions), MIT, `0.3.1` | Built-in DSH 0.2.0 compatibility module, scoped RPC/loopback checks, remaining-usage display, and image-generation status styling. | +| Sidebar provenance | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar), MIT | The standalone plugin is retired; Harness native panels take over the workbench. Attribution is retained for reused workflow code. | +| Model capabilities | [DeepViewer model-capabilities module](apps/dsh-plugin-model-capabilities), MIT, `1.0.2` | Provider/model scanning and reasoning/multimodal configuration, replacing the old `dsh-plugin-reasoning` activation while preserving supported settings. | +| Session map | [Vendored Synapse source](apps/deepviewer-adapter/workflows/vendor/synapse), with its original license and source record | Built-in session-map integration within the DeepViewer adapter. | The historical first-party preview plugin is disabled in favor of the Harness preview. [dashi-taskboard](https://github.com/chuspeeism/dashi-taskboard) was evaluated and removed; no Taskboard runtime is bundled. Upstream copyrights and third-party licenses remain with their respective projects. ## Build from source -Requires macOS arm64, Node.js 24+, and pnpm 11.19.0. The committed patch reconstructs the exact Harness source used for this release from the pinned upstream commit; no user data or prebuilt app is needed. +Requires macOS arm64, Node.js 24+, and pnpm 11.19.0. The bootstrap checks out the pinned Harness source and applies the adapter's checked contracts; no user data or prebuilt app is needed. ```sh git clone https://github.com/Duoasa/DeepViewer.git cd DeepViewer pnpm install --frozen-lockfile -git clone https://github.com/deepseek-ai/deepseek-harness upstream/deepseek-harness -git -C upstream/deepseek-harness checkout fb2c4b9e698e30edb738bca4cf0618587db7d203 -git -C upstream/deepseek-harness apply --whitespace=nowarn ../snapshots/v0.3.3/harness.patch +node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs pnpm --dir upstream/deepseek-harness install --frozen-lockfile -pnpm --dir upstream/deepseek-harness run build:official -pnpm desktop:build +pnpm desktop:dev ``` -`pnpm desktop:build` increments the local build number for continued development. The [snapshot instructions](upstream/snapshots/v0.3.3/README.md) explain how to reproduce fixed Build 75, including Runtime packaging, Developer ID signing, and Apple notarization. Signing credentials stay in the local Keychain and are never committed. +`pnpm desktop:dev` builds and opens DeepViewer Dev using its separate profile. Use `pnpm desktop:dev:restart` to restart that project's development runner, or `pnpm desktop:build` to build without launching. Development builds increment the local build number. + +Basic verification uses `pnpm typecheck` and `pnpm desktop:smoke`; native integration and adapter-contract checks are defined in the [CI workflow](.github/workflows/ci.yml). `pnpm desktop:preview` prepares a local preview. `pnpm desktop:release` prepares signed arm64 DMG/ZIP artifacts and requires `DEEPVIEWER_SIGN_IDENTITY`, `DEEPVIEWER_TEAM_ID`, and `DEEPVIEWER_NOTARY_PROFILE`; it does not publish them. Signing/notary credentials remain local, with the notary profile in the Keychain. + +The [0.3.3 snapshot instructions](upstream/snapshots/v0.3.3/README.md) remain a historical reproduction guide for Build 75, not the build procedure for 0.5.0. The [SDD documents](docs/sdd/README.md) record architecture, plugin provenance, requirements, and verification. DeepViewer's original code uses the [MIT License](LICENSE); all upstream licenses remain applicable. diff --git a/README.zh-CN.md b/README.zh-CN.md index ddeed17..716b168 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,15 +12,15 @@ 最新版本 CI 支持 Apple Silicon Mac - Electron 43 + Electron 44 MIT 许可证 在 GitHub 上讨论

- 下载 0.3.3(ARM64) + 0.5.0 发布候选(ARM64) · - 本版更新 + 本版更新 · 隐私与数据 · @@ -33,8 +33,9 @@ DeepViewer 是建立在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 之上的独立开源桌面 -Agent 工作台。它将固定版本的 Runtime 封装进 macOS 应用,并加入 Work / Chat 空间、 -文件交付、侧栏工作台与桌面控制。 +Agent 工作台。它通过原生 Electron/DesktopHost 将固定版本的 Runtime 封装进 macOS +应用,提供 Work / Chat 空间、文件交付、侧栏工作台和会话图谱。0.5.0 源码承接 +Scoder 0.7.0 Build 43,恢复 DeepViewer 品牌和独立的数据目录。

DeepViewer 0.3.3 将生成图片作为附件交付并在侧栏预览 @@ -44,93 +45,100 @@ Agent 工作台。它将固定版本的 Runtime 封装进 macOS 应用,并加 > DeepViewer 是独立社区项目,与 DeepSeek 没有从属或官方背书关系。 > [!IMPORTANT] -> 当前正式发布为 **DeepViewer 0.3.3 / Build 75**,内置 DeepSeek Harness `0.1.5-rc.2`,**仅支持 Apple Silicon arm64**。DMG 已完成 Developer ID 签名和 Apple 公证。迁移会话、设置与附件的最终界面验收仍由维护者进行。 +> **DeepViewer 0.5.0 正在准备公开发布**,内置 DeepSeek Harness `0.2.0-rc.2`,**仅支持 Apple Silicon arm64**。本次目标是从源码全新生成 Developer ID 签名并经 Apple 公证的 DMG;最终 Build 号、签名/公证结果、校验清单和下载链接待证据确认。界面、真实账户及迁移会话/设置/附件仍保留独立的人工验收边界。 > [!TIP] -> [Release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 提供 SHA-256 校验清单。安装版和开发版现使用相互独立、与版本号无关的数据目录;重大升级前请备份。上一公开 [0.2.5 版本](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) 仍可下载。 +> 安装版和开发版使用相互独立、与版本号无关的数据目录;重大升级前请备份。上一公开 [0.3.3 / Build 75](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 及其 SHA-256 清单、历史 [0.2.5 版本](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) 仍可下载。 -## 相比上一公开版本 0.2.5 的功能变化 +> [!NOTE] +> 本 README 的全部截图均为 **0.3.3 / Build 75 历史参考**,本次没有替换截图,不代表 0.5.0 当前界面或视觉验收结果。 + +## 相比上一公开版本 0.3.3 的功能变化 -| 领域 | 0.3.3 的变化 | +| 领域 | 0.5.0 候选版本的变化 | | --- | --- | -| Work 与 Chat | 将依赖工作区的 Work 会话与无工作区的 Chat 历史区分;可从侧栏创建项目、搜索 Chat 历史。重启后无需逐条打开会话,也能从持久记录恢复模式和标题。 | -| 文件与预览 | 生成的图片及其他文件以附件卡片交付。将工作区外或无后缀的产物复制到会话工作区,并根据真实类型补齐扩展名,使卡片和侧栏预览指向同一文件。 | -| 侧栏工作台 | 集成开源 Better Sidebar,提供文件、编辑器、终端、Git 和浏览器面板;桌面适配窗口控件、相对文件路径、导航与鉴权。 | -| 模型能力 | 增加第一方推理与多模态能力编辑器,按模型进行有边界的能力扫描;扫描通过不等于提供方的所有功能均已实测。 | -| 过程与外观 | 同类上下文和操作合并为可展开摘要,字号与多模态状态跟随会话字号;加强悬停反馈,增加图片生成流光,以及侧栏 12 × 12 px Drive 运行指示。设置布局和系统外观与桌面壳对齐。 | -| 网络 | 内置 Runtime 采用系统代理/PAC,保留显式代理配置;连接错误更清晰,访问内网地址前请求限定范围的授权。 | -| 升级连续性 | 安装版与开发版分别使用固定数据目录。首次启动带备份导入同渠道旧会话和附件;后续应用及 Harness 升级继续使用固定目录。冷启动列表从持久事件恢复 Work/Chat 分类。 | +| 原生桌面 | 从旧自建桌面壳和 DSH 0.1.5 覆盖层迁移到官方 Electron/DesktopHost 与固定 DSH `0.2.0-rc.2`,以 Scoder `0.7.0 / Build 43` 为源码基线。 | +| Work、Chat 与会话图谱 | 保留依赖工作区的 Work 和无工作区的 Chat,增加内置 Synapse 会话图谱,用于查看会话关联与上下文。 | +| 侧栏工作台 | Harness 原生面板承接独立 Better Sidebar 的挂载;DeepViewer 保留文件/编辑器/Git 操作、会话相对路径、桌面浏览器集成与权限检查。 | +| 内置模块 | subscriptions `0.3.1` 和 model-capabilities `1.0.2` 作为应用模块内置;模型能力模块替代旧 reasoning 插件,并承接其支持的设置。 | +| 文件与外观 | 保留附件交付和工作区预览,在原生壳中恢复 DeepViewer 品牌,跟随桌面/系统外观。 | +| 开发稳定性 | 文件同步仅改变产物元数据时保留已挂载的界面;真实源码变化和完成构建的时间戳仍触发热更新。 | +| 升级连续性 | 保留稳定版/开发版独立数据根目录,在新内核打开存储前快照旧 DeepViewer 数据,保留迁移与回滚备份;Scoder 和 Saidex 数据不作为迁移候选。 | +| 网络 | 保留系统代理/PAC、显式代理、限定范围的 loopback/RPC 鉴权与内网访问授权边界。 | -本版**不包含**已经移除的 Taskboard 集成。部分提供方、侧栏及迁移数据流程仍需真实账户和界面人工验收;验证边界见[发布记录](docs/sdd/releases/v0.3.3.md)。 +独立 Better Sidebar 和 reasoning 插件退出挂载,由本次迁移的原生功能与模型能力模块承接。Taskboard 保持移除,历史预览插件保持停用。当前验证边界见 [0.5.0 规格](docs/sdd/specs/DV-0033-scoder-native-rebase/spec.md);[0.3.3 发布记录](docs/sdd/releases/v0.3.3.md)仅提供历史证据。 ### Work、Chat 与过程披露 Work 按项目组织会话,Chat 可以脱离工作区保存对话。两种模式都能在启动后恢复历史;同类上下文、思考、搜索和操作会合并为可展开的摘要。 -![Work 项目与会话历史](Resources/screenshots/work-session.png) +![0.3.3 历史参考:Work 项目与会话历史](Resources/screenshots/work-session.png) -![独立 Chat 与展开的过程披露](Resources/screenshots/chat-and-disclosure.png) +![0.3.3 历史参考:独立 Chat 与展开的过程披露](Resources/screenshots/chat-and-disclosure.png) ### 文件与侧栏工作台 侧栏可以查看工作区文件、预览生成的 HTML,并与对话并排浏览网页。文件路径、面板导航和浏览器访问都与当前桌面会话关联。 -![Work 对话右侧打开网页](Resources/screenshots/browser-workbench.png) +![0.3.3 历史参考:Work 对话右侧打开网页](Resources/screenshots/browser-workbench.png) -![在侧栏预览生成的 HTML 页面](Resources/screenshots/html-preview.png) +![0.3.3 历史参考:在侧栏预览生成的 HTML 页面](Resources/screenshots/html-preview.png) ### 图片生成与附件交付 生成文件以具有可用名称和后缀的附件卡片交付。即使图片最初生成在工作区外,也会复制到会话工作区,让附件与侧栏预览指向同一个文件。 -![生成图片的附件卡片与侧栏预览](Resources/screenshots/image-delivery.png) +![0.3.3 历史参考:生成图片的附件卡片与侧栏预览](Resources/screenshots/image-delivery.png) -![图片生成结果、过程披露与侧栏预览](Resources/screenshots/image-generation.png) +![0.3.3 历史参考:图片生成结果、过程披露与侧栏预览](Resources/screenshots/image-generation.png) ### 外观与版本信息 设置页跟随桌面外观,“关于”页面展示应用构建号和内置 Harness 版本。 -![关于 DeepViewer 显示 0.3.3 Build 75 与 Harness 0.1.5-rc.2](Resources/screenshots/about-version.png) +![0.3.3 历史参考:关于页显示 Build 75 与 Harness 0.1.5-rc.2](Resources/screenshots/about-version.png) ## 安装与数据 -1. 在 [Release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 下载 `DeepViewer-0.3.3-macos-arm64.dmg`,并用 `SHA256SUMS.txt` 校验。 +1. 待 0.5.0 发布后,从 [GitHub Releases](https://github.com/Duoasa/DeepViewer/releases) 下载预期文件 `DeepViewer-0.5.0-arm64.dmg`,并用 `SHA256SUMS.txt` 校验。候选版本的最终资产与下载链接待证据确认。 2. 打开 DMG,将 `DeepViewer.app` 复制到“应用程序”。 3. 打开应用并配置模型提供方或订阅账户。无需全局安装 Node.js 或 Harness。 本地服务仅监听随机 loopback 端口。安装版数据固定存放于 `~/Library/Application Support/DeepViewer`,开发版固定存放于 `~/Library/Application Support/DeepViewer Dev`。覆盖安装应用不会替换这些目录;首次迁移会先复制、校验并保留备份。重大升级前仍建议自行备份。 -公开安装包从明确允许的代码、固定 Runtime、资源和许可证全新构建,不包含维护者的会话、工作区、设置、日志、账户凭据、开发机主目录路径或交接文件。签名发布前会检查包体。 +打包流程从明确允许的代码、固定 Runtime、资源和许可证生成独立包体。正式验收要求检查最终包中的开发机路径、私有数据和越界链接,并核验签名、公证与校验清单。0.5.0 候选包的最终证据仍待确认。 ## 开源引用与 DeepViewer 修改 | 组件 | 来源与许可证 | DeepViewer 的修改 | | --- | --- | --- | -| Agent 内核 | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),MIT,固定在 `fb2c4b9e698e30edb738bca4cf0618587db7d203`(`0.1.5-rc.2`) | 桌面集成及[可复原源码补丁](upstream/snapshots/v0.3.3/README.md),覆盖 Work/Chat、会话恢复、文件交付、过程披露、主题和网络桥接;Agent Loop 仍由上游维护。 | -| 订阅与图片工具 | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions),MIT,`0.3.1` | DSH 0.1.5 兼容适配、限定范围 RPC/loopback 检查、剩余额度显示及图片生成状态样式。 | -| 侧栏工作台 | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),MIT,`0.19.1` | macOS 窗口和设置适配、原生面板路由、会话相对路径、桌面浏览器与鉴权集成。 | -| 模型能力编辑器 | [DeepViewer 第一方插件](apps/dsh-plugin-reasoning),MIT,`0.1.0` | 提供方/模型扫描,以及推理和多模态能力的手动配置。 | +| Agent 内核与原生壳 | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),MIT,固定在 `639ed015397290b3745d163aafe02ffee4aa3f84`(`0.2.0-rc.2`) | 官方 Electron/DesktopHost 加 [DeepViewer adapter](apps/deepviewer-adapter) 与经过检查的兼容契约;Agent Loop 仍由上游维护。 | +| 桌面源码基线 | [Duoasa/scoder](https://github.com/Duoasa/scoder),`0.7.0 / Build 43`,固定在 `e277adc6ce512655095e6f65c63bdc72d24bae95`;保留原始版权说明 | 恢复 DeepViewer 品牌、包名/更新目标和数据隔离,保留原生功能,修复元数据变化引发的界面热卸载;不导入 Scoder 发布记录与用户数据。 | +| 订阅与图片工具 | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions),MIT,`0.3.1` | 内置 DSH 0.2.0 兼容模块,限定范围 RPC/loopback 检查、剩余额度显示及图片生成状态样式。 | +| 侧栏来源 | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),MIT | 独立插件退出挂载,工作台由 Harness 原生面板承接;复用工作流代码保留来源与版权。 | +| 模型能力 | [DeepViewer model-capabilities 模块](apps/dsh-plugin-model-capabilities),MIT,`1.0.2` | 提供方/模型扫描和推理/多模态配置,替代旧 `dsh-plugin-reasoning` 挂载并承接支持的设置。 | +| 会话图谱 | [Vendored Synapse 源码](apps/deepviewer-adapter/workflows/vendor/synapse),保留原始许可证和来源记录 | 在 DeepViewer adapter 中内置会话图谱集成。 | 历史第一方预览插件已停用,改用 Harness 官方预览。[dashi-taskboard](https://github.com/chuspeeism/dashi-taskboard) 曾参与评估,现已移除,Runtime 不再包含 Taskboard。上游版权和第三方许可证仍归各项目所有。 ## 从源码构建 -需要 macOS arm64、Node.js 24+ 和 pnpm 11.19.0。仓库中的补丁可以从固定的 Harness 提交还原本版确切源码,不依赖用户数据或预构建应用。 +需要 macOS arm64、Node.js 24+ 和 pnpm 11.19.0。bootstrap 获取固定的 Harness 源码并应用经过检查的适配契约,不依赖用户数据或预构建应用。 ```sh git clone https://github.com/Duoasa/DeepViewer.git cd DeepViewer pnpm install --frozen-lockfile -git clone https://github.com/deepseek-ai/deepseek-harness upstream/deepseek-harness -git -C upstream/deepseek-harness checkout fb2c4b9e698e30edb738bca4cf0618587db7d203 -git -C upstream/deepseek-harness apply --whitespace=nowarn ../snapshots/v0.3.3/harness.patch +node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs pnpm --dir upstream/deepseek-harness install --frozen-lockfile -pnpm --dir upstream/deepseek-harness run build:official -pnpm desktop:build +pnpm desktop:dev ``` -`pnpm desktop:build` 会为继续开发递增本地 Build 号。[源码快照说明](upstream/snapshots/v0.3.3/README.md)提供复现固定 Build 75 的 Runtime 封包、Developer ID 签名与 Apple 公证步骤。签名凭据只保存在本机钥匙串,不提交到仓库。 +`pnpm desktop:dev` 会构建并启动使用独立数据目录的 DeepViewer Dev;`pnpm desktop:dev:restart` 重启本项目开发进程,`pnpm desktop:build` 仅构建、不启动。开发构建会递增本地 Build 号。 + +基础验证使用 `pnpm typecheck` 和 `pnpm desktop:smoke`,原生集成及适配契约检查见 [CI 流程](.github/workflows/ci.yml)。`pnpm desktop:preview` 制作本地预览包;`pnpm desktop:release` 准备签名 arm64 DMG/ZIP,需要设置 `DEEPVIEWER_SIGN_IDENTITY`、`DEEPVIEWER_TEAM_ID` 和 `DEEPVIEWER_NOTARY_PROFILE`,不会自动发布。签名/公证凭据留在本机,notary profile 保存在钥匙串。 + +[0.3.3 源码快照说明](upstream/snapshots/v0.3.3/README.md)保留为 Build 75 的历史复现指南,不用于 0.5.0 构建。 [SDD 文档](docs/sdd/README.md)记录架构、插件来源、需求和验证。DeepViewer 原创代码采用 [MIT License](LICENSE);各上游许可证继续适用。 diff --git a/apps/deepviewer-adapter/README.md b/apps/deepviewer-adapter/README.md new file mode 100644 index 0000000..70083ea --- /dev/null +++ b/apps/deepviewer-adapter/README.md @@ -0,0 +1,72 @@ +# DeepViewer native adapter + +DeepViewer 0.5.0 uses DSH 0.2.0-rc.2 (`639ed015397290b3745d163aafe02ffee4aa3f84`) and its Electron/private DesktopHost. The upstream checkout is disposable build input. Product adaptations live here; the old 0.1.5 whole-file overlays are not replayed. + +| Directory | Ownership | +| --- | --- | +| `desktop/` | Identity, data roots, Dock theme, system proxy/PAC and private-network authorization | +| `client/` | Branding/About, welcome mascot/font, Work/Chat and native-sidebar editor/Git tabs | +| `workflows/` | Chat isolation, generated-file delivery policy, workspace operations | +| `migrations/` | Profile conversion, version-bound kernel backup and allowlisted frontend state | +| `compatibility/` | Pinned native gaps, imported helper files and regression tests | +| `scripts/` | Validate contracts before modifying disposable upstream sources; stage native bundles | + +Use native filesystem opening/reveal, document/browser previews, delivery cards, change review, PTY/browser/sidebar state, process folding and statistics. Better Sidebar and Taskboard are not runtime dependencies. The Git helper retains its upstream MIT attribution; editor libraries retain their licenses. + +The adapter, subscriptions and model capabilities are application-owned native modules. The shipped Web application composition owns their rows and client settings registrations. Generated CLI metadata owns their dependencies; the upstream installation manifest and lockfile stay unchanged. Integrated manifests omit `dsh.bundle`, and user profiles contain neither their dependencies nor their bundle selections. They have no separate cards, enable switches or uninstall controls in Plugins. Standalone source packages retain their original plugin packaging. + +## Reproduce from the repository root + +Use Node 24 and the root's pinned pnpm. `bootstrap-native-upstream.mjs` checks the tag/commit and anchors without resetting unknown edits. Install the upstream's pinned dependencies after bootstrap. + +```sh +pnpm install --frozen-lockfile +node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs +pnpm --dir upstream/deepseek-harness install --frozen-lockfile +pnpm desktop:build +pnpm typecheck +pnpm desktop:smoke +pnpm --filter @deepviewer/desktop smoke:source +pnpm --filter @deepviewer/desktop smoke:contracts +pnpm --filter @deepviewer/desktop smoke:plugins +pnpm --filter @deepviewer/desktop smoke:native +pnpm --filter @deepviewer/desktop smoke:payload +``` + +`smoke:native` uses private homes, fake APIs and local update feeds. `smoke:payload` materializes the production dependency closure and creates a temporary ASAR; it checks actual native loading and Office conversion without creating a distributable app or DMG. Source maps, development links and other-platform runtime packages are excluded. `smoke:source` applies all 106 contracts twice to a fresh checkout and rejects Agent-loop patches. + +## Compatibility removal conditions + +Every anchor is named and explains its purpose. A changed commit or anchor stops preparation before any source write. Preserve the matching tests when upstream provides an equivalent seam, then remove the contract family and its copied helper together. + +| Contract family | Remove when the pinned upstream provides | Verification | +| --- | --- | --- | +| Dedicated RPC ownership | Explicit consumer-owned channel lifetime under Cordis 4 | RPC owner unload regression; authenticated Host/plugin smoke | +| Work/Chat creation, cold list/history/search and uploads | Cwd-free Chat preset authorization, persisted preset classification and mode-filtered search | Native Host create/prompt/restart/page/search and workspace rejection; attachment fixtures | +| Delivery preparation and source aliases | Hook to normalize generated files before native presentation | Delivery containment and producer-contract fixtures | +| Branding/locales, hero layout and stats slot | Product identity/locales/layout hooks | Native renderer smoke; visual acceptance remains manual | +| Protection tones | Structured warning tone for stale/unread filesystem guards | Native ToolRow 11-case regression | +| Desktop port/network readiness/web fetch | Product-owned launch/network policy seam | Native Host auth/shutdown and PAC/local-access fixtures | +| Update channel/version policy | Product-selected feed channel and release identity | Native updater tests and real HTTP Range reconstruction | +| External client bundling | Supported native module-table builder for external manifests | Native adapter/model/subscriptions builds | +| Native DeepViewer composition | Application-owned native-module composition and diagnostic disable hook | Host inventory/RPC/recovery, source contracts and staged manifest fixtures | + +No contract changes the Agent loop. Native slots and services own sidebar state; DeepViewer owns only the added editor state and product mode selection. Editor saves reject stale content and retain typing made while a save is in flight. Git stage/unstage accepts one selected changed file and only discovered repositories inside the Session workspace. + +## Data and recovery + +Stable DeepViewer and DeepViewer Dev keep their existing independent roots. Explicit `DEEPVIEWER_DEV_USER_DATA` creates an isolated test profile. Scoder, Saidex and official DSH are not migration sources. Before the new kernel opens durable stores, DeepViewer snapshots its own home into private `migration-backups/kernel-v020` with SHA-256 verification; node_modules, locks and caches are excluded. Incomplete copies are never declared complete. `restoreKernelSnapshot(home, destination)` restores to a new separate home and refuses existing destinations, preserving newer sessions. + +Profile conversion backs up metadata before removing retired bundles. The native-module checkpoint uses private `migration-backups/builtins-v1` metadata and keeps its previous migration journal. It removes the three modules' old dependency and bundle declarations, prunes their root lockfile entries and removes only unchanged application-owned links. Provider configuration and prior explicit disable selections become native row overrides. Completion follows successful writes; retry reads the backed-up selection. `rollbackDesktopProfile` restores the previous metadata/journal and owned links without touching accounts or sessions. + +Official recovery disables external plugins while DeepViewer's native modules remain available. `DEEPVIEWER_DISABLE_BUILTINS=1` temporarily starts pure native core and wins over explicit enable overrides. Existing subscriptions/model disable flags remain supported; those individual switches persist row disable overrides. Re-enable by removing the diagnostic flag and the relevant `disabled: true` override in the desktop profile, rather than through Plugins. Frontend migration reads only recorded localhost origins from DeepViewer's own logs, backs up allowlisted drafts/view state and imports missing values into `dsh-app://app/`. It does not import browser credentials or overwrite new-origin drafts. + +## Distribution and update qualification + +The local update harness verifies consecutive/skipped releases, differential byte ranges, missing cache/no-Range/corrupt-blockmap fallback, SHA-512 rejection and HTTP failures. It does not invoke installation. Native task/Host shutdown coordination is retained. No signed application modules are overwritten in place. + +`package-native.mjs --release` requires explicit signing identity/team/keychain notary profile, signs the native runtime, seals its inventory, checks the actual ASAR and notarizes the app and DMG. It builds ZIP/blockmap as the update transport and DMG for installation/recovery; it never publishes. Signed installation, data preservation and relaunch must still be qualified on real macOS installations before publication. + +The signed output directory's `qualification.json` must bind the feed channel/version and ZIP SHA-512, with schemaVersion 1 and all scenarios true: `consecutive`, `skipped`, `fullDownload`, `differentialFallback`, `busyTaskDeferral`, `signedInstall`, `userDataPreserved`, `relaunch`. This is a release evidence record, not a substitute for running those scenarios. `publish-native-update.mjs --publish` rejects missing/stale qualification, verifies the ZIP's actual signed product/version, requires an intentionally provisioned channel Release, uploads immutable ZIP/blockmaps without replacement, and uploads the mutable feed last. + +Stable uses `updates-stable/latest-mac.yml`; preview uses `updates-preview/preview-mac.yml`, with independent updater caches. Public releases increment semver; internal Build changes alone are not public update versions. This milestone has not published those endpoints. diff --git a/apps/deepviewer-adapter/UPSTREAM-LICENSE b/apps/deepviewer-adapter/UPSTREAM-LICENSE new file mode 100644 index 0000000..c1f7a78 --- /dev/null +++ b/apps/deepviewer-adapter/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg b/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg new file mode 100644 index 0000000..67c8e83 --- /dev/null +++ b/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/deepviewer-adapter/assets/fonts/NOTICE.txt b/apps/deepviewer-adapter/assets/fonts/NOTICE.txt new file mode 100644 index 0000000..12064d0 --- /dev/null +++ b/apps/deepviewer-adapter/assets/fonts/NOTICE.txt @@ -0,0 +1,4 @@ +Smiley Sans (得意黑) 2.0.1 — atelierAnchor +https://github.com/atelier-anchor/smiley-sans +Unmodified release asset: SmileySans-Oblique.ttf.woff2 (stored as SmileySans-Oblique.woff2). +License: SIL Open Font License 1.1; see OFL-SmileySans.txt in this directory. diff --git a/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt b/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt new file mode 100644 index 0000000..3eda391 --- /dev/null +++ b/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt @@ -0,0 +1,93 @@ +Copyright (c) 2022--2024, atelierAnchor , +with Reserved Font Name and <得意黑>. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2 b/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2 new file mode 100644 index 0000000..0da5a7f Binary files /dev/null and b/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2 differ diff --git a/apps/deepviewer-adapter/assets/icon-dark.png b/apps/deepviewer-adapter/assets/icon-dark.png new file mode 100644 index 0000000..13da831 Binary files /dev/null and b/apps/deepviewer-adapter/assets/icon-dark.png differ diff --git a/apps/deepviewer-adapter/assets/icon-light.png b/apps/deepviewer-adapter/assets/icon-light.png new file mode 100644 index 0000000..13da831 Binary files /dev/null and b/apps/deepviewer-adapter/assets/icon-light.png differ diff --git a/apps/deepviewer-adapter/assets/synapse/LICENSE b/apps/deepviewer-adapter/assets/synapse/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/apps/deepviewer-adapter/assets/synapse/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/deepviewer-adapter/assets/synapse/app.js b/apps/deepviewer-adapter/assets/synapse/app.js new file mode 100644 index 0000000..ef3a9dd --- /dev/null +++ b/apps/deepviewer-adapter/assets/synapse/app.js @@ -0,0 +1,1765 @@ +const app = document.querySelector('#app') +if ('scrollRestoration' in history) history.scrollRestoration = 'manual' +const UI_STATE_KEY = 'deepviewer:synapse:view:v1' +const savedBranchAnchors = [] +const savedCardPositions = [] +const savedCollapsedCards = [] +let locale = 'zh-CN' +const copy = { + map: ['地图', 'Map'], detail: ['详情', 'Details'], compare: ['对比', 'Compare'], + arrange: ['整理', 'Arrange'], locate: ['定位', 'Locate'], zoomOut: ['缩小', 'Zoom out'], zoomIn: ['放大', 'Zoom in'], + mapViews: ['会话地图视图', 'Map views'], arrangeHint: ['整理节点', 'Arrange cards'], locateHint: ['定位到当前会话', 'Locate current conversation'], + sourceSession: ['DeepViewer 会话', 'DeepViewer conversation'], sourceBranch: ['DeepViewer 分支', 'DeepViewer branch'], followup: ['追问', 'Follow up'], + addFollowup: ['添加追问', 'Add follow-up'], expandChildren: ['展开后续对话', 'Expand following turns'], collapseChildren: ['折叠后续对话', 'Collapse following turns'], + branchNew: ['在新对话中分支', 'Branch into a new conversation'], dragCard: ['拖动卡片', 'Drag card'], drag: ['拖动', 'Drag'], + viewConversation: ['查看完整会话', 'View conversation'], failed: ['失败', 'Failed'], tools: ['工具', 'Tools'], waiting: ['等待助手回复', 'Waiting for a reply'], + replying: ['正在回复', 'Replying'], turnFailed: ['本轮失败', 'Turn failed'], openNative: ['在 DeepViewer 中打开', 'Open in DeepViewer'], + hideConversation: ['从地图隐藏此会话', 'Hide conversation from map'], hide: ['隐藏', 'Hide'], close: ['关闭', 'Close'], + emptyMap: ['当前会话还没有可展示的对话。', 'There are no turns to show yet.'], emptyMapHelp: ['使用下方输入框开始对话,发送后会自动显示在地图中。', 'Start a conversation in the composer below. New turns appear on the map.'], + processFallback: ['工具调用记录', 'Tool call record'], you: ['你', 'You'], error: ['错误', 'Error'], record: ['记录', 'Record'], branch: ['分支', 'Branch'], + branchAnswer: ['从此回答创建分支', 'Branch from this reply'], collapseProcess: ['收起过程记录', 'Collapse process records'], expandProcess: ['展开过程记录', 'Expand process records'], + collapse: ['收起', 'Collapse'], expand: ['展开', 'Expand'], waitingResult: ['等待结果', 'Waiting for result'], done: ['完成', 'Done'], process: ['过程记录', 'Process records'], + toolResult: ['工具结果', 'Tool result'], incomplete: ['本轮未完成', 'This turn is incomplete'], continueFollowup: ['继续追问', 'Continue conversation'], + createBranch: ['创建分支', 'Create branch'], cardDetails: ['卡片详情', 'Card details'], closeCardDetails: ['关闭卡片详情', 'Close card details'], + session: ['会话', 'Conversation'], openNativeHint: ['在原生对话中打开此会话', 'Open this conversation in the native view'], branchLatest: ['基于最新回答创建分支', 'Branch from the latest reply'], + emptyDetail: ['等待这条会话的第一条消息。', 'Waiting for the first message.'], waitQuestion: ['等待用户提问', 'Waiting for a question'], + selectionFollowup: ['基于所选内容创建追问', 'Follow up on the selected text'], selectionHint: ['基于所选内容追问', 'Ask about the selected text'], + noSource: ['该节点没有关联的 DeepViewer 会话', 'This card has no linked DeepViewer conversation'], standalone: ['请从 DeepViewer 的会话地图中操作', 'Open the conversation map in DeepViewer'], + timeout: ['DeepViewer 未在规定时间内响应', 'DeepViewer did not respond in time'], activeCollapsed: ['当前会话位于这个后续分支中,请先切换会话', 'Switch conversations before collapsing the active branch'], + waitBranch: ['请等待这张卡片的最终回答后再创建分支', 'Wait for the final reply before creating a branch'], + compareEmpty: ['选择两张卡片进行对比', 'Select two cards to compare'], compareHelp: ['在地图卡片下方点击“对比”,可以并排查看两个回答。', 'Choose Compare on two map cards to read their replies side by side.'], + attachmentMessage: ['附件消息', 'Attachment message'], + cardActions: ['继续或创建分支', 'Continue or create a branch'], continueSession: ['继续当前会话', 'Continue this conversation'], + continueHint: ['切到此会话的最新位置,在下方输入框继续。', 'Switch to the latest turn in this conversation and use the composer below.'], + branchFromReply: ['从此回答创建分支', 'Branch from this reply'], branchHint: ['保留到此回答的上下文,在新会话中继续。', 'Keep the context through this reply and continue in a new conversation.'], + preparingDraft: ['正在准备输入框…', 'Preparing the composer…'], draftPrepared: ['已切到此会话,请在下方输入框继续。', 'This conversation is ready. Continue in the composer below.'], + branchPrepared: ['新分支已准备,请在下方输入框开始。', 'The new branch is ready. Start in the composer below.'], + compareSelected: ['已加入对比', 'Selected for comparison'], clearCompare: ['清除对比', 'Clear comparison'], +} +const t = (key, ...values) => { + const value = copy[key]?.[locale === 'en-US' ? 1 : 0] ?? key + return values.reduce((text, item, index) => text.replaceAll(`{${index}}`, String(item)), value) +} +const turnLabel = value => locale === 'en-US' ? `Turn ${value}` : `第 ${value} 轮` +const hideConfirm = title => locale === 'en-US' + ? `Hide “${title}” and its branches from the map? The original DeepViewer conversations remain available.` + : `从地图隐藏「${title}」及其分支?DeepViewer 原会话会保留,可继续查看。` +const CARD_WIDTH = 310 +const CARD_HEIGHT = 276 +const CARD_GAP_Y = 42 +const CAMERA_INSET_X = 56 +const CAMERA_INSET_Y = 56 +// Cards outside the viewport (plus this world-space margin) are not mounted +// into the DOM; the margin pre-mounts cards just before they scroll into view +// so panning never flashes empty space. +const VIEWPORT_MARGIN = 1400 +const state = { + summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, compareCardIds: [], + dshWorkspaces: [], selectedDshWorkspaceId: null, + historyBySession: new Map(), historyRequests: new Map(), pendingRpc: new Map(), liveReplies: new Map(), + error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), + dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, + expandedMessageIds: new Set(), + canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false, + detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null, + inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(), + actionMenuCardId: null, composePending: false, notice: '', pointerPressed: false, renderDeferred: false, +} + +const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])) +const formatTime = value => new Date(value).toLocaleString(locale, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) +const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null + +function viewStateKey(sessionId = state.currentDsh?.id) { + return typeof sessionId === 'string' ? `${UI_STATE_KEY}:${sessionId}` : null +} +function persistViewState(sessionId = state.currentDsh?.id, overrides = {}) { + const key = viewStateKey(sessionId) + if (key === null) return + const detail = app.querySelector('.detail-scroll') + if (detail instanceof HTMLElement && state.activeId !== null) state.detailScrollByThread.set(state.activeId, detail.scrollTop) + const inspector = app.querySelector('.card-inspector-scroll') + if (inspector instanceof HTMLElement && state.inspectorCardId !== null) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop) + // Store geometry and identifiers only. Messages, titles and drafts stay in the native session store. + const snapshot = { camera: state.canvasCamera, initialized: state.canvasViewInitialized, zoom: state.zoom, + mode: state.mode, activeId: state.activeId, selectedCardId: state.selectedCardId, inspectorCardId: state.inspectorCardId, + compareCardIds: state.compareCardIds, branchAnchors: [...state.branchAnchors], cardPositions: [...state.cardPositions], + collapsedCardIds: [...state.collapsedCardIds], expandedMessageIds: [...state.expandedMessageIds], + detailScroll: [...state.detailScrollByThread], inspectorScroll: [...state.inspectorScrollByCard], ...overrides } + try { sessionStorage.setItem(key, JSON.stringify(snapshot)) } catch { /* Storage may be disabled. */ } +} +function restoreViewState(sessionId) { + const key = viewStateKey(sessionId) + let saved + try { saved = key === null ? null : JSON.parse(sessionStorage.getItem(key) ?? 'null') } catch { saved = null } + const validId = value => typeof value === 'string' && value.length <= 512 + const idList = values => Array.isArray(values) ? values.filter(validId).slice(0, 10000) : [] + const positionList = values => Array.isArray(values) ? values.filter(item => Array.isArray(item) && validId(item[0]) && Number.isFinite(item[1]?.x) && Number.isFinite(item[1]?.y)).slice(0, 10000) : [] + const scrollList = values => Array.isArray(values) ? values.filter(item => Array.isArray(item) && validId(item[0]) && Number.isFinite(item[1]) && item[1] >= 0).slice(0, 10000) : [] + state.mode = ['canvas', 'thread', 'compare'].includes(saved?.mode) ? saved.mode : 'canvas' + state.zoom = Number.isFinite(saved?.zoom) ? Math.min(4, Math.max(.6, saved.zoom)) : 1 + state.canvasCamera = Number.isFinite(saved?.camera?.x) && Number.isFinite(saved?.camera?.y) ? saved.camera : { x: 0, y: 0 } + state.canvasViewInitialized = saved?.initialized === true + state.activeId = validId(saved?.activeId) ? saved.activeId : null + state.selectedCardId = validId(saved?.selectedCardId) ? saved.selectedCardId : null + state.inspectorCardId = validId(saved?.inspectorCardId) ? saved.inspectorCardId : null + state.inspectorOpening = false + state.detailTargetCardId = validId(saved?.detailTargetCardId) ? saved.detailTargetCardId : null + state.compareCardIds = idList(saved?.compareCardIds).slice(0, 2) + state.cardPositions = new Map(positionList(saved?.cardPositions)) + state.collapsedCardIds = new Set(idList(saved?.collapsedCardIds)) + state.expandedMessageIds = new Set(idList(saved?.expandedMessageIds)) + state.branchAnchors = new Map(Array.isArray(saved?.branchAnchors) ? saved.branchAnchors.filter(item => Array.isArray(item) && item.every(validId)).slice(0, 10000) : []) + state.detailScrollByThread = new Map(scrollList(saved?.detailScroll)) + state.inspectorScrollByCard = new Map(scrollList(saved?.inspectorScroll)) + return saved !== null +} +function rememberBranchAnchor(sessionId, cardId) { state.branchAnchors.set(sessionId, cardId); persistViewState() } +function persistCardPositions() { persistViewState() } +function persistCollapsedCards() { persistViewState() } +window.addEventListener('pagehide', () => persistViewState()) +app.addEventListener('scroll', () => persistViewState(), true) + +function rememberCardPosition(cardId, position, aliases = []) { + state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) }) + for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) }) + persistCardPositions() +} + +function resetCardPositions() { state.cardPositions.clear(); persistViewState() } + +function resetCanvasCamera() { + state.canvasViewInitialized = false + state.canvasCamera = { x: 0, y: 0 } +} + +async function api(path, options = {}) { + return dshRpc('synapse:api', { path, method: options.method ?? 'GET', body: options.body }); +} + +function post(type, payload = {}) { + if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin) +} + +function dshRpc(type, payload = {}) { + if (window.parent === window) return Promise.reject(new Error(t('standalone'))) + const requestId = crypto.randomUUID() + post(type, { requestId, ...payload }) + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + state.pendingRpc.delete(requestId) + reject(new Error(t('timeout'))) + }, 20_000) + state.pendingRpc.set(requestId, { resolve, reject, timer }) + }) +} + +function settleRpc(requestId, value, error) { + const pending = state.pendingRpc.get(requestId) + if (pending === undefined) return + state.pendingRpc.delete(requestId) + window.clearTimeout(pending.timer) + if (error === undefined) pending.resolve(value) + else pending.reject(error instanceof Error ? error : new Error(String(error))) +} + +function setError(error = '') { state.error = error instanceof Error ? error.message : error; state.notice = ''; render() } + +async function loadThreadHistory() {} + +function canReplaceView() { + return !state.pointerPressed && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea') +} + +function deferCanvasRefresh(delay = 700) { + state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay) +} + +function currentDshWorkspace() { + const id = state.currentDsh?.id + return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined +} + +function selectedDshWorkspace() { + return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId) +} + +function currentDshThread(threads = state.workspace?.threads ?? []) { + const id = state.currentDsh?.id + return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined +} + +async function threadsForDshWorkspace(workspace) { + if (workspace.sessionIds.length === 0) return [] + const requested = new Set(workspace.sessionIds) + const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`))) + return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId))) +} + +async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) { + const workspace = state.dshWorkspaces.find(item => item.id === id) + if (workspace === undefined) return false + const load = ++state.workspaceLoad + state.selectedDshWorkspaceId = id + const threads = await threadsForDshWorkspace(workspace) + if (load !== state.workspaceLoad) return true + const nextWorkspaceId = `dsh:${workspace.id}` + if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera && !state.canvasViewInitialized) resetCanvasCamera() + state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads } + const currentThread = currentDshThread(state.workspace.threads) + state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : currentThread?.id ?? state.workspace.threads[0]?.id ?? null + if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id) + if (renderAfter && canReplaceView()) render() + await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false))) + if (renderAfter && load === state.workspaceLoad && canReplaceView()) render() + return true +} + +async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) { + const workspace = currentDshWorkspace() + if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false + return openDshWorkspace(workspace.id, { preserveCanvasCamera }) +} + +async function refreshSummaries({ renderAfter = true } = {}) { + const before = JSON.stringify(state.summaries) + const body = await api('/synapse/api/workspaces') + state.summaries = body.workspaces + const changed = before !== JSON.stringify(state.summaries) + const current = state.workspace?.id + if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null + const selected = selectedDshWorkspace() + if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter }) + else if (state.workspace === null && state.currentDsh !== null) { + const summary = state.summaries.find(item => Array.isArray(item.sessionIds) && item.sessionIds.includes(state.currentDsh.id)) + if (summary !== undefined) await openWorkspace(summary.id) + } + else if (renderAfter && changed && canReplaceView()) render() + return changed +} + +async function openWorkspace(id, { renderAfter = true } = {}) { + const load = ++state.workspaceLoad + const body = await api(`/synapse/api/workspaces/${id}`) + if (load !== state.workspaceLoad) return + if (state.workspace?.id !== body.workspace.id && !state.canvasViewInitialized) resetCanvasCamera() + state.workspace = body.workspace + state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null + if (renderAfter && canReplaceView()) render() + await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false))) + if (renderAfter && load === state.workspaceLoad && canReplaceView()) render() +} + +let projectionRequest = null +let projectionDirty = false +function refreshProjection({ force = false } = {}) { + // Native turn events can precede a summary revision. Keep the detail refresh + // pending through pointer/drag gestures, and coalesce streaming notifications. + projectionDirty ||= force + if (projectionRequest !== null) return projectionRequest + projectionRequest = (async () => { + const summariesChanged = await refreshSummaries({ renderAfter: false }) + projectionDirty ||= summariesChanged + if (!projectionDirty || state.workspace === null || !canReplaceView()) return summariesChanged + projectionDirty = false + if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId) + else await openWorkspace(state.workspace.id) + projectionDirty ||= !canReplaceView() || [...state.liveReplies].some(([sessionId, live]) => { + if (!Number.isSafeInteger(live.userSeq)) return false + const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId) + return thread === undefined || !persistedMessagesFor(thread).some(message => message.kind === 'user' && message.sourceSeq === live.userSeq) + }) + return true + })().catch(error => { projectionDirty = true; throw error }).finally(() => { projectionRequest = null }) + return projectionRequest +} + +async function archiveThread(thread) { + if (!window.confirm(hideConfirm(thread.title))) return + await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' }) + state.historyBySession.delete(thread.dshSessionId) + state.detailScrollByThread.delete(thread.id) + state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId + if (state.workspace !== null) { + const removed = new Set([thread.id]) + for (let changed = true; changed;) { + changed = false + for (const item of state.workspace.threads) { + if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) { + removed.add(item.id) + changed = true + } + } + } + state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id)) + for (const key of [...state.cardPositions.keys()]) { + if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key) + } + let collapsedChanged = false + for (const key of [...state.collapsedCardIds]) { + if ([...removed].some(id => key.startsWith(`${id}:`))) { + state.collapsedCardIds.delete(key) + collapsedChanged = true + } + } + if (collapsedChanged) persistCollapsedCards() + state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId) + ? state.activeId + : state.workspace.threads[0]?.id ?? null + render() + } else { + state.activeId = null + } + await refreshSummaries() +} + +async function composeNative(parent, { text, atSeq, fork = false, anchorId } = {}) { + if (state.composePending) return + if (typeof parent.dshSessionId !== 'string') throw new Error(t('noSource')) + state.composePending = true + state.error = '' + state.notice = '' + updateComposeControls() + persistViewState() + try { + const result = await dshRpc('synapse:compose', { sessionId: parent.dshSessionId, text, atSeq, fork, anchorId }) + if (fork && typeof result?.id === 'string') { + if (anchorId !== undefined) rememberBranchAnchor(result.id, anchorId) + persistViewState(result.id) + } + closeCardActionMenu() + state.notice = t(fork ? 'branchPrepared' : 'draftPrepared') + updateComposeNotice() + } finally { + state.composePending = false + updateComposeControls() + } +} +function openContinue(parent, anchorId = undefined, text = undefined) { return composeNative(parent, { text, anchorId }) } +function openBranch(parent, atSeq = undefined, anchorId = undefined) { return composeNative(parent, { atSeq, fork: true, anchorId }) } + +function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) } +function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] } + +function messagesFor(thread) { + // Host projection owns source filtering. A user may intentionally quote internal-looking text. + const messages = persistedMessagesFor(thread) + const live = state.liveReplies.get(thread.dshSessionId) + if (live?.running !== true || !Number.isSafeInteger(live.userSeq) || !messages.some(message => message.kind === 'user' && message.sourceSeq === live.userSeq)) return messages + const index = messages.findIndex(message => message.kind === 'user' && message.sourceSeq === live.userSeq) + const next = messages.findIndex((message, offset) => offset > index && message.kind === 'user') + const end = next === -1 ? messages.length : next + return [...messages.slice(0, end), { kind: 'assistant', text: live.text, pending: true, at: new Date().toISOString() }, ...messages.slice(end)] +} + +function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) } +function messageText(message) { + return message?.kind === 'user' && message.deepviewerAttachmentOnly === true ? t('attachmentMessage') : message?.text ?? '' +} +function questionFor(thread) { + const question = latestMessage(thread, 'user') + return question === undefined ? thread.dshSessionTitle ?? t('waitQuestion') : messageText(question) +} +function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null } + +function inlineMarkdown(text) { + return escapeHtml(text) + .replace(/`([^`\n]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2') +} + +const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim()) + +const isTableDelimiter = line => { + const cells = tableCells(line) + return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell)) +} + +function markdownBlock(text) { + const lines = text.split('\n') + const output = [] + for (let index = 0; index < lines.length;) { + const line = lines[index] + if (line.trim() === '') { index++; continue } + const heading = /^(#{1,3})\s+(.+)$/.exec(line) + if (heading !== null) { + const level = heading[1].length + output.push(`${inlineMarkdown(heading[2])}`) + index++ + continue + } + const unordered = /^[-*+]\s+(.+)$/.exec(line) + const ordered = /^\d+[.)]\s+(.+)$/.exec(line) + if (unordered !== null || ordered !== null) { + const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/ + const items = [] + while (index < lines.length) { + const item = matcher.exec(lines[index]) + if (item === null) break + items.push(`

  • ${inlineMarkdown(item[1])}
  • `) + index++ + } + output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}`) + continue + } + // GFM table: a leading-pipe header row followed by a |-delimiter row, + // then any number of leading-pipe body rows. + if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) { + const header = line + const body = [] + index += 2 + while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) { + body.push(lines[index]) + index++ + } + output.push(`${tableCells(header).map(cell => ``).join('')}${body.map(row => `${tableCells(row).map(cell => ``).join('')}`).join('')}
    ${inlineMarkdown(cell)}
    ${inlineMarkdown(cell)}
    `) + continue + } + const paragraph = [] + while (index < lines.length && lines[index].trim() !== '' && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index]) && !/^\d+[.)]\s+/.test(lines[index])) paragraph.push(lines[index++]) + // A marker-only line such as PowerShell's "+ " diagnostic is neither a + // list item nor paragraph content under the rules above. Consume it so + // the parser always makes progress. + if (paragraph.length === 0) paragraph.push(lines[index++]) + output.push(`

    ${paragraph.map(inlineMarkdown).join('
    ')}

    `) + } + return output.join('') +} + +// Markdown parsing is pure CPU and repeats for every card on every canvas +// rebuild; cache the rendered HTML by input text so stable answers are never +// re-parsed. Bounded: streaming partial texts churn keys, so evict oldest. +const markdownCache = new Map() +const MARKDOWN_CACHE_LIMIT = 5000 +function renderMarkdown(text) { + const key = String(text) + const cached = markdownCache.get(key) + if (cached !== undefined) return cached + const parts = key.split(/```/) + const rendered = parts.map((part, index) => index % 2 === 1 + ? `
    ${escapeHtml(part.replace(/^\w*\n/, ''))}
    ` + : markdownBlock(part)).join('') + if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value) + markdownCache.set(key, rendered) + return rendered +} + +function overlapsCard(position, other) { + return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x + && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y +} + +function firstAvailableCardPosition(position, occupied) { + const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) } + while (true) { + const collisions = occupied.filter(other => overlapsCard(candidate, other)) + if (collisions.length === 0) return candidate + candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y)) + } +} + +function connectorPath(fromPosition, toPosition) { + const fromX = fromPosition.x + CARD_WIDTH + const fromY = fromPosition.y + CARD_HEIGHT / 2 + const toX = toPosition.x + const toY = toPosition.y + CARD_HEIGHT / 2 + const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2)) + return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}` +} + +function connectorPathFromElements(fromCard, toCard) { + const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH + const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2 + const toX = Number.parseFloat(toCard.style.left) + const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2 + if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null + const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2)) + return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}` +} + +function selectorValue(value) { + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +// Connector paths are rebuilt together with the canvas DOM; cache the mapping +// from card id to its incident paths so dragging never scans the whole SVG. +let connectorPathsByCard = new Map() +function cacheCardConnectors() { + connectorPathsByCard = new Map() + const viewport = document.querySelector('.canvas-viewport') + if (!(viewport instanceof HTMLElement)) return + for (const path of viewport.querySelectorAll('.connectors path[data-from]')) { + const fromId = path.getAttribute('data-from') + const toId = path.getAttribute('data-to') + if (fromId === null || toId === null) continue + for (const id of [fromId, toId]) { + const paths = connectorPathsByCard.get(id) + if (paths === undefined) connectorPathsByCard.set(id, new Set([path])) + else paths.add(path) + } + } +} + +function refreshCardConnectors(cardId) { + const paths = connectorPathsByCard.get(cardId) + if (paths === undefined || paths.size === 0) return + const byId = state.canvasCardsById + if (byId === undefined) return + for (const path of paths) { + const fromId = path.getAttribute('data-from') + const toId = path.getAttribute('data-to') + if (fromId === null || toId === null) continue + const fromCard = byId.get(fromId) + const toCard = byId.get(toId) + if (fromCard === undefined || toCard === undefined) continue + // Data-driven endpoints: the counterpart card may be unmounted (outside + // the viewport) but its position is still authoritative. + path.setAttribute('d', connectorPath(fromCard.position, toCard.position)) + } +} + +function initialCanvasCamera(cards) { + // The native composer owns the current route. Local inspection is separate. + const route = nativeCanvasRoute(cards) + const focus = cards.find(card => card.id === route.currentCardId) ?? cards[0] + const position = focus?.position + if (position === undefined) return { x: 0, y: 0 } + return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom } +} + +function placeConversationCards(cards) { + const saved = new Map(cards.flatMap(card => { + if (card.positionLocked !== true) return [] + const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey) + return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]] + })) + const occupied = [] + for (const card of cards) { + const position = saved.get(card.id) + if (position !== undefined) { + card.position = position + continue + } + card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied) + occupied.push(card.position) + } + return cards +} + +function layoutConversationGraph(cards, threads) { + const childrenByThread = new Map() + for (const thread of threads) { + if (thread.parentId === null) continue + const children = childrenByThread.get(thread.parentId) ?? [] + children.push(thread.id) + childrenByThread.set(thread.parentId, children) + } + const laneByThread = new Map() + const visitThread = threadId => { + if (laneByThread.has(threadId)) return + laneByThread.set(threadId, laneByThread.size) + for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId) + } + for (const thread of threads) if (thread.parentId === null) visitThread(thread.id) + for (const thread of threads) visitThread(thread.id) + + const byId = new Map(cards.map(card => [card.id, card])) + const positioned = new Map() + const positionFor = (card, visiting = new Set()) => { + if (positioned.has(card.id)) return positioned.get(card.id) + if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) } + visiting.add(card.id) + const parent = card.parentId === null ? undefined : byId.get(card.parentId) + const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting) + const position = { + x: parentPosition === undefined ? 86 : parentPosition.x + 365, + y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y), + } + visiting.delete(card.id) + positioned.set(card.id, position) + return position + } + for (const card of cards) { + card.naturalPosition = positionFor(card) + if (!card.positionLocked) card.position = card.naturalPosition + } + return placeConversationCards(cards) +} + +function conversationCards(threads) { + const cards = [] + const cardsByThread = new Map() + for (const thread of threads) { + const messages = messagesFor(thread) + const turns = [] + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const question = messages[messageIndex] + if (question.kind !== 'user') continue + const replies = [] + const errors = [] + let processCount = 0 + for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) { + const reply = messages[replyIndex] + if (reply.kind === 'user') break + if (reply.kind === 'assistant') replies.push(reply) + if (reply.kind === 'error') errors.push(reply) + if (Array.isArray(reply.process)) processCount += reply.process.length + else if (reply.kind === 'tool') processCount += 1 + } + const answer = replies.at(-1) ?? null + const error = errors.at(-1) ?? null + const turnIndex = turns.length + const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}` + const previous = turns.at(-1) + const positionKey = `${thread.id}:turn-index:${turnIndex}` + const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y } + const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey) + const positionLocked = savedPosition !== undefined + const position = positionLocked ? savedPosition : naturalPosition + turns.push({ + id, + positionKey, + dshThreadId: thread.id, + sourceParentId: thread.parentId, + parentId: null, + sourceSeq: question.sourceSeq, + turnIndex, + naturalPosition, + position, + positionLocked, + question: messageText(question), + answer, + error, + processCount, + }) + } + const liveReply = state.liveReplies.get(thread.dshSessionId) + const latestTurn = turns.find(turn => turn.sourceSeq === liveReply?.userSeq) + if (liveReply?.running && Number.isSafeInteger(liveReply.userSeq) && latestTurn !== undefined && (latestTurn.answer === null || latestTurn.answer.pending === true)) latestTurn.answer = { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } + if (turns.length === 0) { + const id = `${thread.id}:turn:empty` + const positionKey = `${thread.id}:turn-index:0` + const naturalPosition = { x: 86, y: 82 } + const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey) + const positionLocked = savedPosition !== undefined + turns.push({ + id, + positionKey, + dshThreadId: thread.id, + sourceParentId: thread.parentId, + parentId: null, + sourceSeq: undefined, + turnIndex: 0, + naturalPosition, + position: positionLocked ? savedPosition : naturalPosition, + positionLocked, + question: thread.dshSessionTitle ?? thread.title, + answer: null, + error: null, + processCount: 0, + }) + } + turns.at(-1).canContinue = true + cardsByThread.set(thread.id, turns) + cards.push(...turns) + } + for (const card of cards) { + const siblings = cardsByThread.get(card.dshThreadId) + if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id + else { + const parentCards = cardsByThread.get(card.sourceParentId) + const sourceThread = threads.find(thread => thread.id === card.dshThreadId) + const firstChildQuestion = siblings?.[0] + const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq + // A fork inherits every parent event before DeepViewer's durable seed boundary. + // The latest parent question below that boundary is the exact Turn where + // this child was born. Canvas coordinates never participate in lineage. + const inheritedTurn = Number.isSafeInteger(seedLength) + ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1) + : undefined + card.parentId = state.branchAnchors.get(threads.find(thread => thread.id === card.dshThreadId)?.dshSessionId) ?? state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null + } + } + return layoutConversationGraph(cards, threads) +} + +function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) { + const cardIds = new Set(cards.map(card => card.id)) + const childrenByParent = new Map() + for (const card of cards) { + if (card.parentId === null || !cardIds.has(card.parentId)) continue + const children = childrenByParent.get(card.parentId) ?? [] + children.push(card.id) + childrenByParent.set(card.parentId, children) + } + + const hiddenIds = new Set() + for (const rootId of collapsedCardIds) { + if (!cardIds.has(rootId)) continue + const visited = new Set([rootId]) + const visit = parentId => { + for (const childId of childrenByParent.get(parentId) ?? []) { + if (visited.has(childId)) continue + visited.add(childId) + hiddenIds.add(childId) + visit(childId) + } + } + visit(rootId) + } + + // Persisted collapse roots must remain visible even if malformed metadata + // contains a cycle where two collapsed nodes otherwise hide each other. + for (const rootId of collapsedCardIds) hiddenIds.delete(rootId) + + // Post-order accumulation: each card's descendant count is 1 + the sum of + // its children's subtree sizes, so the whole graph is O(n) instead of a BFS + // from every card (O(n²) on deep chains). Malformed parent cycles are + // detected through the DFS path: every member of a cycle reaches every other + // member plus the union of their off-cycle subtrees, so when the cycle entry + // pops last, all members are settled to (cycleSize - 1) + off-cycle total, + // which matches the per-card BFS' unique-descendant count. + const descendantCounts = new Map() + const inStack = new Set() + for (const card of cards) { + if (descendantCounts.has(card.id)) continue + const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }] + const path = [card.id] + let cycleEntry = null + let cycleMembers = null + let cycleOffCycleTotal = 0 + inStack.add(card.id) + while (stack.length > 0) { + const top = stack[stack.length - 1] + if (top.index < top.children.length) { + const childId = top.children[top.index++] + if (descendantCounts.has(childId)) continue + if (inStack.has(childId)) { + // Back edge: the nodes from childId up to top.id form a cycle. + cycleEntry = childId + cycleMembers = new Set(path.slice(path.indexOf(childId))) + cycleOffCycleTotal = 0 + continue + } + inStack.add(childId) + path.push(childId) + stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 }) + } else { + stack.pop() + path.pop() + inStack.delete(top.id) + let count = 0 + for (const childId of top.children) { + if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below + count += 1 + (descendantCounts.get(childId) ?? 0) + } + if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count + if (cycleMembers !== null && top.id === cycleEntry) { + // All cycle members have popped (the entry pops last in post-order); + // settle them so ancestors popping next read the final counts. + const base = cycleMembers.size - 1 + for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal) + cycleEntry = null + cycleMembers = null + } else { + descendantCounts.set(top.id, count) + } + } + } + } + + return { + cards: cards.filter(card => !hiddenIds.has(card.id)), + childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])), + descendantCounts, + } +} + +function revealConversationThread(cards, threadId) { + const byId = new Map(cards.map(card => [card.id, card])) + let changed = false + for (const target of cards.filter(card => card.dshThreadId === threadId)) { + const visited = new Set([target.id]) + let parentId = target.parentId + while (parentId !== null && !visited.has(parentId)) { + visited.add(parentId) + if (state.collapsedCardIds.delete(parentId)) changed = true + parentId = byId.get(parentId)?.parentId ?? null + } + } + if (changed) persistCollapsedCards() +} + +// Derive the native route from the full graph once, never from persisted UI +// selection. Child lineage includes the exact inherited parent turn and every +// predecessor through the native conversation's latest turn. +function nativeCanvasRoute(cards) { + const nativeThread = currentDshThread() + const currentCard = nativeThread === undefined ? undefined : cards.filter(card => card.dshThreadId === nativeThread.id).at(-1) + const lineage = new Set() + const byId = new Map(cards.map(card => [card.id, card])) + let card = currentCard + while (card !== undefined && !lineage.has(card.id)) { + lineage.add(card.id) + card = card.parentId === null ? undefined : byId.get(card.parentId) + } + return { currentCardId: currentCard?.id ?? null, lineage } +} + +function updateNativeCanvasRoute() { + if (state.mode !== 'canvas' || state.canvasGraph === undefined || state.canvasCards === undefined) return + const route = nativeCanvasRoute(state.canvasGraph.allCards ?? state.canvasCards) + state.canvasGraph.nativeRoute = route + for (const card of app.querySelectorAll('.thread-card')) { + const current = card.dataset.cardId === route.currentCardId + card.classList.toggle('current', current) + if (current) card.setAttribute('aria-current', 'true') + else card.removeAttribute('aria-current') + } + for (const connector of app.querySelectorAll('.connectors path')) { + connector.classList.toggle('active-connector', route.lineage.has(connector.dataset.from) && route.lineage.has(connector.dataset.to)) + } +} + +function canvasConnectors(cards, route) { + const index = new Map(cards.map(card => [card.id, card])) + const links = cards.map(card => { + const parent = card.parentId === null ? null : index.get(card.parentId) + if (parent === undefined || parent === null) return '' + const active = route.lineage.has(card.id) && route.lineage.has(parent.id) ? ' active-connector' : '' + return `` + }) + return links.join('') +} + +function conversationCard(card, graph) { + const selected = card.id === state.selectedCardId ? 'selected' : '' + const current = card.id === graph.nativeRoute?.currentCardId + const source = card.parentId === null ? t('sourceSession') : card.turnIndex === 0 ? t('sourceBranch') : t('followup') + const continueButton = card.canContinue === true + ? `` + : '' + const childCount = graph.childCounts.get(card.id) ?? 0 + const collapsed = state.collapsedCardIds.has(card.id) + const foldLabel = collapsed ? t('expandChildren') : t('collapseChildren') + const foldButton = childCount === 0 || card.canContinue === true ? '' : `` + const branchButton = childCount === 0 || card.canContinue === true || !Number.isInteger(card.answer?.sourceSeq) ? '' : `` + return `
    + + ${continueButton}${foldButton}${branchButton} +
    +
    ${source}${turnLabel(card.turnIndex + 1)}${card.error === null ? '' : `${t('failed')}`}${card.processCount > 0 ? `${t('tools')} ${card.processCount}` : ''}
    +
    ${card.answer === null ? (card.error === null ? `

    ${t('waiting')}

    ` : '') : card.answer.pending && card.answer.text === '' ? `

    ${t('replying')}

    ` : `${renderMarkdown(card.answer.text)}${card.answer.pending ? `

    ${t('replying')}

    ` : ''}`}${card.error === null ? '' : `

    ${t('turnFailed')}: ${escapeHtml(card.error.text)}

    `}
    + +
    ` +} + +function selectionFollowupButton() { + return `` +} + +// Cards are mounted into the DOM only when they intersect the viewport +// (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is +// translate(camera) scale(zoom), so screen = world * zoom + camera. +function visibleCardIds(cards) { + const viewport = document.querySelector('.canvas-viewport') + if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id)) + const bounds = viewport.getBoundingClientRect() + const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom + const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom + const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom + const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom + const visible = new Set() + for (const card of cards) { + const { x, y } = card.position + if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue + visible.add(card.id) + } + return visible +} + +// Incrementally mount cards entering the viewport and unmount cards leaving +// it, without rebuilding the canvas. Called after pan/zoom/focus camera moves. +function syncCanvasViewport() { + if (state.mode !== 'canvas' || state.canvasCards === undefined) return + const layer = document.querySelector('.cards-layer') + if (!(layer instanceof HTMLElement)) return + const visible = visibleCardIds(state.canvasCards) + for (const cardId of [...state.mountedCardIds]) { + if (visible.has(cardId)) continue + const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`) + if (element instanceof HTMLElement) element.remove() + state.mountedCardIds.delete(cardId) + } + for (const card of state.canvasCards) { + if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue + const wrapper = document.createElement('div') + wrapper.innerHTML = conversationCard(card, state.canvasGraph) + const element = wrapper.firstElementChild + if (element instanceof HTMLElement) { + layer.appendChild(element) + const handle = element.querySelector('[data-drag-card]') + if (handle instanceof HTMLElement) bindDragHandle(handle) + } + state.mountedCardIds.add(card.id) + } +} + +function renderCanvas() { + const threads = state.workspace?.threads ?? [] + if (threads.length === 0) return `
    ${t('emptyMap')}

    ${t('emptyMapHelp')}

    ` + const allCards = conversationCards(threads) + const graph = conversationGraphView(allCards) + graph.allCards = allCards + graph.nativeRoute = nativeCanvasRoute(allCards) + const cards = graph.cards + state.canvasCards = cards + state.canvasCardsById = new Map(cards.map(card => [card.id, card])) + state.canvasGraph = graph + if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) { + state.inspectorCardId = null + state.inspectorOpening = false + } + if (!state.canvasViewInitialized) { + state.canvasCamera = initialCanvasCamera(cards) + state.canvasViewInitialized = true + // The viewport is not laid out yet while renderCanvas builds its HTML; + // center the focused card once the DOM is mounted (render tail). + state.canvasNeedsCenter = true + } + const visible = visibleCardIds(cards) + state.mountedCardIds = new Set(visible) + const mounted = cards.filter(card => visible.has(card.id)) + const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId)) + return `
    ${canvasConnectors(cards, graph.nativeRoute)}
    ${mounted.map(card => conversationCard(card, graph)).join('')}
    ${inspector}
    ` +} + +function isProcessMessage(message) { + if (message.kind === 'tool' || message.kind === 'tool-result') return true + return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text) +} + +function processSummary(text) { + return text.replace(/\s+/g, ' ').trim().slice(0, 140) || t('processFallback') +} + +function threadMessage(thread, message) { + const isUser = message.kind === 'user' + const label = isUser ? t('you') : message.kind === 'assistant' ? 'DeepViewer' : message.kind === 'error' ? t('error') : t('record') + const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq) + ? `` + : '' + const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}` + const collapsible = isProcessMessage(message) + const expanded = state.expandedMessageIds.has(messageId) + const fold = collapsible ? `` : '' + const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null + const body = message.pending && message.text === '' ? `

    ${t('replying')}

    ` + : `${collapsible && !expanded ? `

    ${escapeHtml(processSummary(message.text))}

    ` : renderMarkdown(messageText(message))}${message.pending ? `

    ${t('replying')}

    ` : ''}${process === null ? '' : processRecords(process, messageId)}` + const avatar = isUser ? '' : '' + return `
    ${avatar}${label}${branch}${fold}
    ${body}
    ` +} + +function processRecords(process, messageId) { + const key = `${messageId}:process` + const expanded = state.expandedMessageIds.has(key) + const entries = process.map((entry, index) => { + const entryKey = `${key}:${index}` + const entryExpanded = state.expandedMessageIds.has(entryKey) + const status = entry.error !== null ? t('failed') : entry.result === null ? t('waitingResult') : t('done') + const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `
    ${escapeHtml(entry.arguments)}
    ` + const outcomeHtml = entry.error !== null ? `
    ${escapeHtml(entry.error)}
    ` : entry.result === null ? '' : `
    ${escapeHtml(entry.result)}
    ` + return `
    ${entryExpanded ? `
    ${argumentsHtml}${outcomeHtml}
    ` : ''}
    ` + }).join('') + return `
    ${expanded ? entries : ''}
    ` +} + +function messagesForCard(card) { + const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId) + if (thread === undefined) return { thread: null, messages: [] } + const messages = messagesFor(thread) + let turnIndex = -1 + let start = -1 + for (let index = 0; index < messages.length; index++) { + if (messages[index].kind !== 'user') continue + turnIndex += 1 + if (turnIndex === card.turnIndex) { + start = index + break + } + } + if (start === -1) return { thread, messages: [] } + const end = messages.findIndex((message, index) => index > start && message.kind === 'user') + return { thread, messages: messages.slice(start, end === -1 ? undefined : end) } +} + +function inspectorProcessEntries(messages) { + const entries = [] + for (const message of messages) { + if (Array.isArray(message.process)) { + entries.push(...message.process.map(entry => ({ ...entry }))) + continue + } + if (message.kind === 'tool') { + entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null }) + continue + } + if (message.kind === 'tool-result') { + const previous = entries.at(-1) + if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text + else entries.push({ name: t('toolResult'), arguments: null, result: message.text, error: null }) + } + } + return entries +} + +function renderCardInspector(card) { + if (card === undefined) return '' + const { thread, messages } = messagesForCard(card) + if (thread === null) return '' + const process = inspectorProcessEntries(messages) + const answer = card.answer === null + ? card.error === null ? `

    ${t('waiting')}

    ` : '' + : `
    ${renderMarkdown(card.answer.text)}${card.answer.pending ? `

    ${t('replying')}

    ` : ''}
    ` + const error = card.error === null ? '' : `` + const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`) + const continueAction = card.canContinue === true ? `` : '' + const branch = Number.isInteger(card.answer?.sourceSeq) + ? `` + : '' + const openDshAction = `` + return `` +} + +function renderThread() { + const thread = currentThread() + if (thread === null) return renderCanvas() + const messages = messagesFor(thread) + const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq + return `
    ${thread.parentId === null ? t('session') : t('branch')}${thread.dshSessionTitle ?? thread.title ? `${escapeHtml(thread.dshSessionTitle ?? thread.title)}` : ''}

    ${escapeHtml(questionFor(thread))}

    ${messages.map(message => threadMessage(thread, message)).join('') || `
    ${t('emptyDetail')}
    `}
    ` +} + +function renderCompare() { + const cards = conversationCards(state.workspace?.threads ?? []) + const selected = state.compareCardIds.map(id => cards.find(card => card.id === id)).filter(Boolean) + if (selected.length < 2) return `
    ${t('compareEmpty')}

    ${t('compareHelp')}

    ` + return `
    ${selected.map(card => `

    ${escapeHtml(card.question)}

    ${turnLabel(card.turnIndex + 1)}

    ${card.answer === null ? `

    ${t('waiting')}

    ` : renderMarkdown(card.answer.text)}${card.error === null ? '' : `

    ${escapeHtml(card.error.text)}

    `}
    `).join('')}
    ` +} + +function render() { + // Keep the actual pressed DOM node mounted until the browser dispatches click. + // Native subscriptions may arrive between pointerdown and pointerup. + if (state.pointerPressed) { state.renderDeferred = true; return } + state.renderDeferred = false + // Remember the departing thread's scroll position per thread id, so + // switching sessions restores each conversation's own place instead of + // smearing one session's position onto another. + if (state.mode === 'thread' && state.detailThreadId !== null) { + const detail = document.querySelector('.detail-scroll') + if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop) + } + if (state.mode === 'canvas' && state.inspectorCardId !== null) { + const inspector = document.querySelector('.card-inspector-scroll') + if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop) + } + state.detailThreadId = state.mode === 'thread' ? state.activeId : null + const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null + const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null + const cardScrollTops = new Map() + if (state.mode === 'canvas') { + // Key by the unique card id: every card of a session shares data-thread, + // so keying on it would clobber sibling cards' scroll positions. Only + // scrollable answers have a position worth preserving; reading the two + // height properties shares the same forced layout as the scrollTop read. + for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) { + if (answer.scrollHeight <= answer.clientHeight) continue + const card = answer.closest('.thread-card') + if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop) + } + } + const workspace = state.workspace + const threads = workspace?.threads ?? [] + const view = state.mode === 'thread' ? renderThread() : state.mode === 'compare' ? renderCompare() : renderCanvas() + const canvasControls = state.mode === 'canvas' && threads.length > 0 ? `
    ${Math.round(state.zoom * 100)}%
    ` : '' + const detailAvailable = currentThread() !== null + const canvasTabs = `` + app.innerHTML = `
    ${canvasTabs}${canvasControls}
    ${state.error ? `` : ''}${view}${selectionFollowupButton()}${renderCardActionMenu()}${renderComposeNotice()}
    ` + persistViewState() + installDragging() + updateComposeControls() + positionCardActionMenu() + queueLayoutReport() + cacheCardConnectors() + // The initial camera from renderCanvas is inset (viewport not laid out yet); + // center it on the focused card once the canvas DOM is mounted. + if (state.canvasNeedsCenter) { + state.canvasNeedsCenter = false + window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() }) + } + for (const [cardId, scrollTop] of cardScrollTops) { + const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`) + if (answer instanceof HTMLElement) answer.scrollTop = scrollTop + } + if (detailScrollTop !== null) window.requestAnimationFrame(() => { + const nextDetail = document.querySelector('.detail-scroll') + if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop + }) + if (inspectorScrollTop !== null) window.requestAnimationFrame(() => { + const inspector = document.querySelector('.card-inspector-scroll') + if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop + }) + if (state.inspectorOpening) window.requestAnimationFrame(() => { + document.querySelector('.card-inspector')?.classList.remove('is-opening') + state.inspectorOpening = false + }) + // Jump the detail view to the card the user clicked: card ids carry the + // source sequence (`:turn:`), which matches data-message-seq + // anchors on the rendered messages. + const targetCardId = state.detailTargetCardId + state.detailTargetCardId = null + if (targetCardId !== null) { + const match = /:turn:(\d+)$/.exec(targetCardId) + const seq = match === null ? null : match[1] + if (seq !== null) window.requestAnimationFrame(() => { + const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`) + if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' }) + }) + } +} + +function renderPreservingDetailScroll() { + render() +} + +let inspectorCloseTimer = 0 +function openCardInspector(cardId) { + if (inspectorCloseTimer !== 0) { + window.clearTimeout(inspectorCloseTimer) + inspectorCloseTimer = 0 + } + state.inspectorOpening = state.inspectorCardId === null + state.inspectorCardId = cardId +} + +function closeCardInspector({ animate = true } = {}) { + if (state.inspectorCardId === null) return + if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer) + const cardId = state.inspectorCardId + const inspector = document.querySelector('.card-inspector') + if (!animate || !(inspector instanceof HTMLElement)) { + state.inspectorCardId = null + state.inspectorOpening = false + inspector?.remove() + persistViewState() + queueLayoutReport() + return + } + inspector.classList.add('is-closing') + inspectorCloseTimer = window.setTimeout(() => { + inspectorCloseTimer = 0 + if (state.inspectorCardId !== cardId) return + state.inspectorCardId = null + state.inspectorOpening = false + document.querySelector('.card-inspector')?.remove() + persistViewState() + queueLayoutReport() + }, 180) +} + +// Card inspection is local browsing. Native session navigation occurs only +// after an explicit Continue, Branch, or Open in DeepViewer action. +function selectCanvasCard(cardId) { + const card = state.canvasCardsById?.get(cardId) + if (card === undefined) return + const previousInspector = app.querySelector('.card-inspector-scroll') + if (previousInspector instanceof HTMLElement && state.inspectorCardId !== null) state.inspectorScrollByCard.set(state.inspectorCardId, previousInspector.scrollTop) + const unchanged = state.inspectorCardId === cardId + state.activeId = card.dshThreadId + state.selectedCardId = cardId + state.error = '' + app.querySelector('.status-message')?.remove() + for (const element of app.querySelectorAll('.thread-card')) element.classList.toggle('selected', element.dataset.cardId === cardId) + const detailTab = app.querySelector('.canvas-tabs [data-action="show-thread"]') + if (detailTab instanceof HTMLElement) detailTab.dataset.thread = card.dshThreadId + openCardInspector(cardId) + if (!unchanged || !app.querySelector('.card-inspector')) { + const wrapper = document.createElement('div') + wrapper.innerHTML = renderCardInspector(card) + const next = wrapper.firstElementChild + const previous = app.querySelector('.card-inspector') + if (previous) previous.replaceWith(next) + else app.querySelector('.canvas-view')?.append(next) + const scroll = next.querySelector('.card-inspector-scroll') + if (scroll instanceof HTMLElement) scroll.scrollTop = state.inspectorScrollByCard.get(cardId) ?? 0 + window.requestAnimationFrame(() => { next.classList.remove('is-opening'); state.inspectorOpening = false }) + } + persistViewState() + queueLayoutReport() +} + +function renderCardActionMenu() { + const card = state.canvasCardsById?.get(state.actionMenuCardId) + if (card === undefined || state.mode !== 'canvas') return '' + const canBranch = Number.isInteger(card.answer?.sourceSeq) && card.answer?.pending !== true + return `` +} +function positionCardActionMenu() { + const menu = app.querySelector('.card-action-menu') + if (!(menu instanceof HTMLElement)) return + const anchor = app.querySelector(`[data-action="show-card-actions"][data-card="${CSS.escape(state.actionMenuCardId)}"]`) + if (!(anchor instanceof HTMLElement)) return closeCardActionMenu() + const rect = anchor.getBoundingClientRect() + const left = Math.min(window.innerWidth - menu.offsetWidth - 8, rect.right + 8) + const top = Math.min(window.innerHeight - menu.offsetHeight - 8, rect.top) + menu.style.left = `${Math.max(8, left)}px` + menu.style.top = `${Math.max(8, top)}px` +} +function closeCardActionMenu() { + state.actionMenuCardId = null + app.querySelector('.card-action-menu')?.remove() + for (const button of app.querySelectorAll('[data-action="show-card-actions"]')) button.setAttribute('aria-expanded', 'false') +} +function openCardActionMenu(cardId) { + if (state.composePending) return + if (state.actionMenuCardId === cardId) return closeCardActionMenu() + closeCardActionMenu() + state.actionMenuCardId = cardId + app.querySelector('.main-stage')?.insertAdjacentHTML('beforeend', renderCardActionMenu()) + const anchor = app.querySelector(`[data-action="show-card-actions"][data-card="${CSS.escape(cardId)}"]`) + anchor?.setAttribute('aria-expanded', 'true') + positionCardActionMenu() + app.querySelector('.card-action-menu button:not(:disabled)')?.focus({ preventScroll: true }) +} +function updateComposeControls() { + for (const button of app.querySelectorAll('[data-action="open-continue"], [data-action="open-branch"], [data-action="show-card-actions"], [data-action="follow-selection"]')) { + button.disabled = state.composePending || button.dataset.action === 'open-branch' && button.dataset.card !== undefined && !Number.isInteger(Number(button.dataset.seq || NaN)) + } + const menu = app.querySelector('.card-action-menu') + if (menu instanceof HTMLElement) menu.setAttribute('aria-busy', String(state.composePending)) +} +function renderComposeNotice() { + return state.notice ? `
    ${escapeHtml(state.notice)}
    ` : '' +} +function updateComposeNotice() { + app.querySelector('.compose-notice')?.remove() + app.querySelector('.main-stage')?.insertAdjacentHTML('beforeend', renderComposeNotice()) +} +let layoutReportFrame = 0 +let previousInspectorWidth = -1 +function queueLayoutReport() { + if (layoutReportFrame !== 0) return + layoutReportFrame = window.requestAnimationFrame(() => { + layoutReportFrame = 0 + const inspector = app.querySelector('.card-inspector') + const inspectorWidth = window.innerWidth > 640 && inspector instanceof HTMLElement ? inspector.offsetWidth : 0 + if (inspectorWidth === previousInspectorWidth) return + previousInspectorWidth = inspectorWidth + post('synapse:layout', { inspectorWidth }) + }) +} +window.addEventListener('resize', () => { queueLayoutReport(); positionCardActionMenu() }) + +function applyCanvasTransform() { + const content = document.querySelector('.canvas-content') + if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})` + persistViewState() + positionCardActionMenu() +} + +function bindDragHandle(handle) { + handle.addEventListener('pointerdown', event => { + const cardId = event.currentTarget.dataset.dragCard + const card = event.currentTarget.closest('.thread-card') + if (cardId === undefined || !(card instanceof HTMLElement)) return + event.preventDefault() + const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } } + const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey] + let position = origin.position + let stopped = false + let frame = 0 + state.dragging = true + // Coalesce pointermove updates to one DOM pass per animation frame so a + // high report-rate pointer cannot queue a reflow per event. + const apply = () => { + frame = 0 + state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) }) + for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) }) + // Keep the virtualized data object in sync so viewport visibility and + // connector paths track the live drag position. + const dataCard = state.canvasCardsById?.get(cardId) + if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y } + card.style.left = `${position.x}px` + card.style.top = `${position.y}px` + refreshCardConnectors(cardId) + } + const move = moveEvent => { + position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom } + if (frame === 0) frame = window.requestAnimationFrame(apply) + } + const stop = () => { + if (stopped) return + stopped = true + document.removeEventListener('pointermove', move) + document.removeEventListener('pointerup', stop) + document.removeEventListener('pointercancel', stop) + if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 } + apply() + rememberCardPosition(cardId, position, aliases) + state.dragging = false + deferCanvasRefresh(120) + // No full render: only the dragged card's inline position and its + // connectors changed; rebuilding the whole canvas on drop is the jank. + } + document.addEventListener('pointermove', move) + document.addEventListener('pointerup', stop) + document.addEventListener('pointercancel', stop) + }) +} + +function installDragging() { + for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle) +} + +function canvasViewport(target) { + return target instanceof Element ? target.closest('.canvas-viewport') : null +} + +function zoomCanvas(viewport, nextZoom, clientX, clientY) { + const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100)) + if (zoom === state.zoom) return + const bounds = viewport.getBoundingClientRect() + const localX = clientX - bounds.left + const localY = clientY - bounds.top + const worldX = (localX - state.canvasCamera.x) / state.zoom + const worldY = (localY - state.canvasCamera.y) / state.zoom + state.zoom = zoom + state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom } + const content = viewport.querySelector('.canvas-content') + if (content instanceof HTMLElement) { + // Drop the composited layer before zooming: a cached will-change raster + // would be upscaled instead of re-rasterized, which was the original + // zoom-blur bug. will-change re-applies via .is-panning on the next pan. + content.style.willChange = 'auto' + applyCanvasTransform() + syncCanvasViewport() + window.requestAnimationFrame(() => { content.style.willChange = '' }) + } else { + applyCanvasTransform() + syncCanvasViewport() + } + const label = document.querySelector('.canvas-controls span') + if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%` +} + +function zoomCanvasAtCenter(delta) { + const viewport = document.querySelector('.canvas-viewport') + if (!(viewport instanceof HTMLElement)) return + const bounds = viewport.getBoundingClientRect() + zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2) +} + +function focusActiveCard() { + const viewport = document.querySelector('.canvas-viewport') + if (!(viewport instanceof HTMLElement)) return + const cards = state.canvasCards + if (cards === undefined || cards.length === 0) return + const route = state.canvasGraph?.nativeRoute ?? nativeCanvasRoute(cards) + const card = cards.find(card => card.id === route.currentCardId) ?? cards[0] + const { x: left, y: top } = card.position + const bounds = viewport.getBoundingClientRect() + state.canvasCamera = { + x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom, + y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom, + } + applyCanvasTransform() + syncCanvasViewport() +} + +let selectionFollowup = null +let selectionFollowupFrame = 0 + +function hideSelectionFollowup() { + if (selectionFollowupFrame !== 0) { + window.cancelAnimationFrame(selectionFollowupFrame) + selectionFollowupFrame = 0 + } + selectionFollowup = null + const button = app.querySelector('.selection-followup') + if (button instanceof HTMLButtonElement) button.hidden = true +} + +function selectionFollowupTarget(range) { + const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement + const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement + if (!(start instanceof Element) || !(end instanceof Element)) return null + const answer = start.closest('.thread-answer') + if (answer instanceof HTMLElement && answer.contains(end)) { + const card = answer.closest('.thread-card[data-thread]') + if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread } + } + const messageBody = start.closest('.message-assistant .message-body') + const thread = currentThread() + if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id } + return null +} + +function updateSelectionFollowup() { + selectionFollowupFrame = 0 + const button = app.querySelector('.selection-followup') + const selection = window.getSelection() + if (!(button instanceof HTMLButtonElement) || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup() + const text = selection.toString().trim() + const range = selection.getRangeAt(0) + const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range) + const rect = range.getBoundingClientRect() + if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup() + selectionFollowup = { ...target, text } + button.dataset.thread = target.threadId + button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px` + button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px` + button.hidden = false +} + +function queueSelectionFollowup() { + if (selectionFollowupFrame !== 0) return + selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup) +} + +app.addEventListener('pointerdown', event => { + const viewport = canvasViewport(event.target) + if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return + event.preventDefault() + const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } } + let pendingCamera = null + let frame = 0 + state.canvasGesture = true + viewport.classList.add('is-panning') + viewport.setPointerCapture(event.pointerId) + const apply = () => { + frame = 0 + if (pendingCamera === null) return + state.canvasCamera = pendingCamera + pendingCamera = null + applyCanvasTransform() + syncCanvasViewport() + } + const move = moveEvent => { + pendingCamera = { + x: origin.camera.x + moveEvent.clientX - origin.x, + y: origin.camera.y + moveEvent.clientY - origin.y, + } + if (frame === 0) frame = window.requestAnimationFrame(apply) + } + const stop = () => { + viewport.classList.remove('is-panning') + document.removeEventListener('pointermove', move) + document.removeEventListener('pointerup', stop) + document.removeEventListener('pointercancel', stop) + if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 } + apply() + state.canvasGesture = false + deferCanvasRefresh(120) + } + document.addEventListener('pointermove', move) + document.addEventListener('pointerup', stop) + document.addEventListener('pointercancel', stop) +}) + +app.addEventListener('wheel', event => { + const viewport = canvasViewport(event.target) + if (!(viewport instanceof HTMLElement)) return + const card = event.target instanceof Element ? event.target.closest('.thread-card') : null + if (card instanceof HTMLElement) { + // Over a card the wheel scrolls that card's own answer with the browser's + // native wheel (OS-smooth, never a page jump per notch); the answer's + // overscroll-behavior: contain stops the scroll chaining into the canvas. + const answer = card.querySelector('.thread-answer') + if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) { + deferCanvasRefresh() + return + } + // A card with no scrollable answer swallows the wheel instead of zooming. + event.preventDefault() + deferCanvasRefresh() + return + } + event.preventDefault() + zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY) +}, { passive: false }) + +// Track pointer-down so the card click handler can tell a plain click from a +// text-selection or drag gesture; acting on the latter would re-render and +// wipe the user's selection. +let pointerDownPosition = null +app.addEventListener('pointerdown', event => { + pointerDownPosition = { x: event.clientX, y: event.clientY } + if (event.button !== 0) return + state.pointerPressed = true + if (event.target instanceof Element && !event.target.closest('.card-action-menu, [data-action="show-card-actions"]')) closeCardActionMenu() +}) +// The browser fires click immediately after pointerup. Release on the next task +// so incoming subscriptions cannot replace its target during that event chain. +const releasePointer = () => window.setTimeout(() => { + state.pointerPressed = false + if (state.renderDeferred && canReplaceView()) render() +}, 0) +document.addEventListener('pointerup', releasePointer, true) +document.addEventListener('pointercancel', releasePointer, true) +app.addEventListener('pointerdown', event => { + const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null + if (button instanceof HTMLButtonElement) event.preventDefault() + else hideSelectionFollowup() +}) +app.addEventListener('pointerup', queueSelectionFollowup) +app.addEventListener('scroll', hideSelectionFollowup, true) +document.addEventListener('selectionchange', queueSelectionFollowup) +document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return + if (state.actionMenuCardId !== null) { event.preventDefault(); closeCardActionMenu(); return } + if (state.mode !== 'canvas' || state.inspectorCardId === null) return + event.preventDefault() + closeCardInspector({ animate: false }) +}) + +app.addEventListener('click', async event => { + state.pointerPressed = false + const button = event.target instanceof Element ? event.target.closest('[data-action]') : null + if (!(button instanceof HTMLElement)) { + const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]') : null + if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return + // A double-click selects a word and a drag selects a range; neither is a + // select-click, so leave the selection intact instead of re-rendering. + if (event.detail > 1) return + if (event.detail > 0 && pointerDownPosition !== null + && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return + const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread) + if (thread === undefined) return + const cardId = card.dataset.cardId + if (cardId === undefined) return + selectCanvasCard(cardId) + return + } + const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread) + try { + if (button.dataset.action === 'follow-selection') { + const followup = selectionFollowup + hideSelectionFollowup() + if (followup !== null && thread !== undefined && thread.id === followup.threadId) await openContinue(thread, undefined, followup.text) + return + } + if (button.dataset.action === 'show-card-actions' && button.dataset.card !== undefined) { openCardActionMenu(button.dataset.card); return } + if (button.dataset.action === 'dismiss-notice') { state.notice = ''; updateComposeNotice(); return } + if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return } + if (button.dataset.action === 'show-thread' && thread !== undefined) { + state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null + if (button.dataset.card !== undefined) { state.selectedCardId = button.dataset.card; state.inspectorCardId = button.dataset.card; state.inspectorOpening = false } + render(); void loadThreadHistory(thread) + } + if (button.dataset.action === 'show-compare') { state.mode = 'compare'; render() } + if (button.dataset.action === 'clear-compare') { state.compareCardIds = []; render() } + if (button.dataset.action === 'toggle-compare' && button.dataset.card !== undefined) { + const id = button.dataset.card + state.compareCardIds = state.compareCardIds.includes(id) ? state.compareCardIds.filter(item => item !== id) : [...state.compareCardIds.slice(-1), id] + render() + } + if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() } + if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) { + const cardId = button.dataset.card + const collapsing = !state.collapsedCardIds.has(cardId) + if (collapsing && state.workspace !== null) { + const allCards = conversationCards(state.workspace.threads) + const nextCollapsed = new Set(state.collapsedCardIds).add(cardId) + const visibleCards = conversationGraphView(allCards, nextCollapsed).cards + const visibleIds = new Set(visibleCards.map(card => card.id)) + const nativeRoute = nativeCanvasRoute(allCards) + if (nativeRoute.currentCardId !== null && !visibleIds.has(nativeRoute.currentCardId) + || state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError(t('activeCollapsed')) + } + collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId) + persistCollapsedCards() + render() + window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0) + } + if (button.dataset.action === 'open-continue' && thread !== undefined) await openContinue(thread, button.dataset.card) + if (button.dataset.action === 'open-branch' && thread !== undefined) { + const requestedSeq = button.dataset.seq === undefined || button.dataset.seq === '' ? NaN : Number(button.dataset.seq) + if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError(t('waitBranch')) + const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq + await openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card) + } + if (button.dataset.action === 'toggle-message' && button.dataset.message !== undefined) { state.expandedMessageIds.has(button.dataset.message) ? state.expandedMessageIds.delete(button.dataset.message) : state.expandedMessageIds.add(button.dataset.message); renderPreservingDetailScroll() } + if (button.dataset.action === 'open-dsh' && typeof thread?.dshSessionId === 'string') post('synapse:open-session', { sessionId: thread.dshSessionId, seq: button.dataset.seq !== undefined && button.dataset.seq !== '' && Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined }) + if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread) + if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1) + if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1) + if (button.dataset.action === 'focus-active') focusActiveCard() + if (button.dataset.action === 'dismiss-error') { state.error = ''; render() } + if (button.dataset.action === 'layout' && state.workspace !== null) { + resetCardPositions() + resetCanvasCamera() + render() + } + } catch (error) { setError(error) } +}) + +window.addEventListener('message', event => { + if (event.source !== window.parent || event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return + const data = event.data + if (data.type === 'synapse:map-opened') { + // Do NOT reset the camera here: toggling dialog<->map for the same + // session must keep the user's viewport. A fresh canvas (canvasView + // not initialized) still centers via renderCanvas; a real session switch + // re-centers in the current-session handler below. + render() + window.requestAnimationFrame(() => post('synapse:map-ready')) + } + if (data.type === 'synapse:layout') { + if (Number.isFinite(data.composerHeight) && data.composerHeight >= 0) { + document.documentElement.style.setProperty('--map-composer-height', `${Math.min(window.innerHeight, data.composerHeight)}px`) + queueLayoutReport() + positionCardActionMenu() + } + } + if (data.type === 'synapse:presentation') { + const before = `${locale}|${document.documentElement.style.cssText}` + locale = data.locale === 'en-US' ? 'en-US' : 'zh-CN' + document.documentElement.lang = locale + document.title = locale === 'en-US' ? 'DeepViewer Conversation Map' : 'DeepViewer 会话地图' + const size = Number.isFinite(data.fontSize) ? Math.min(32, Math.max(10, data.fontSize)) : 14 + document.documentElement.style.setProperty('--map-font-size', `${size}px`) + if (typeof data.fontFamily === 'string' && data.fontFamily.length < 500) document.documentElement.style.setProperty('--map-font-family', data.fontFamily) + const tokens = data.tokens ?? {} + for (const name of ['background', 'surface', 'text', 'muted', 'border', 'accent', 'hover', 'subtle', 'error']) { + if (typeof tokens[name] === 'string' && CSS.supports('color', tokens[name])) document.documentElement.style.setProperty(`--map-${name}`, tokens[name]) + } + if (before !== `${locale}|${document.documentElement.style.cssText}`) render() + } + if (data.type === 'synapse:theme') { + document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light' + } + if (data.type === 'synapse:workspaces') { + const previousWorkspaces = JSON.stringify(state.dshWorkspaces) + state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : [] + if (previousWorkspaces === JSON.stringify(state.dshWorkspaces) && state.workspace !== null) return + const current = currentDshWorkspace() + if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError) + else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError) + else if (canReplaceView()) render() + } + if (data.type === 'synapse:current-session') { + const previousId = state.currentDsh?.id + if (previousId === data.session?.id) { state.currentDsh = data.session; updateNativeCanvasRoute(); return } + closeCardActionMenu() + if (previousId !== undefined) persistViewState(previousId) + const restored = restoreViewState(data.session?.id) + state.currentDsh = data.session + updateNativeCanvasRoute() + if (typeof data.session?.id !== 'string') { state.workspace = null; state.activeId = null; state.selectedDshWorkspaceId = null; render(); return } + const preserveCanvasCamera = restored + const thread = currentDshThread() + if (thread !== undefined) { + const preserveSelectedCard = state.activeId === thread.id + if (!restored) state.activeId = thread.id + if (!preserveSelectedCard && !restored) { + state.selectedCardId = null + state.inspectorCardId = null + state.inspectorOpening = false + } + if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id) + } + if (previousId !== data.session?.id) { + // A real session switch: re-center on the new session's latest turn, + // whether it lives in the same workspace (openCurrentWorkspace returns + // false) or a different one (it resets the camera itself). + void openCurrentWorkspace({ preserveCanvasCamera }).then(async opened => { + if (!opened && currentDshThread() === undefined) await refreshProjection({ force: true }) + if (!opened && canReplaceView()) { + render() + if (!preserveCanvasCamera) focusActiveCard() + } + }).catch(setError) + } + else if (canReplaceView()) render() + } + if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') { + const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId) + if (thread !== undefined) { + if (data.running === true) { + state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '', userSeq: Number.isSafeInteger(data.userSeq) ? data.userSeq : undefined }) + if (Number.isSafeInteger(data.userSeq) && !persistedMessagesFor(thread).some(message => message.kind === 'user' && message.sourceSeq === data.userSeq)) void refreshProjection({ force: true }).catch(setError) + // Streaming: patch the live card's answer in place instead of + // rebuilding the whole canvas on every chunk; a full render reconciles + // at stream end. The detail view is single-thread, so keep its cheap + // throttled full render. + if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId) + else if (canReplaceView()) scheduleLiveRender() + } else { + state.liveReplies.delete(data.sessionId) + if (canReplaceView()) renderPreservingDetailScroll() + void refreshProjection({ force: true }).catch(setError) + } + } + } + if (data.type === 'synapse:composed') settleRpc(data.requestId, data.session ?? data) + if (data.type === 'synapse:api-result') settleRpc(data.requestId, data.value) + if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) } +}) + +post('synapse:request-current') +refreshSummaries().catch(setError) +let polling = false +let liveRenderTimer = 0 +let liveCardFrame = 0 +let liveCardSessionId = null +function scheduleLiveCardUpdate(sessionId) { + // Coalesce streaming chunks to one DOM patch per animation frame. + liveCardSessionId = sessionId + if (liveCardFrame !== 0) return + liveCardFrame = window.requestAnimationFrame(() => { + liveCardFrame = 0 + if (liveCardSessionId === null) return + const id = liveCardSessionId + liveCardSessionId = null + applyLiveReplyToCard(id) + }) +} +function applyLiveReplyToCard(sessionId) { + if (state.mode !== 'canvas' || state.dragging || state.canvasGesture) return + const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId) + const live = state.liveReplies.get(sessionId) + if (thread === undefined || live?.running !== true || !Number.isSafeInteger(live.userSeq)) return + updateNativeCanvasRoute() + const dataCard = state.canvasCards?.find(card => card.dshThreadId === thread.id && card.sourceSeq === live.userSeq) + if (dataCard === undefined) return + const card = app.querySelector(`.thread-card[data-card-id="${CSS.escape(dataCard.id)}"]`) + const answer = card?.querySelector('.thread-answer') + const markup = `${live.text.trim() === '' ? '' : renderMarkdown(live.text)}

    ${t('replying')}

    ` + if (answer instanceof HTMLElement) answer.innerHTML = markup + // Details follows only its selected turn; a later turn must not replace an older answer. + if (state.inspectorCardId === dataCard.id) { + const inspector = app.querySelector('.card-inspector-scroll') + const current = inspector?.querySelector('.card-inspector-answer, .card-inspector-pending') + if (current instanceof HTMLElement) { + const scroll = inspector.scrollTop + current.outerHTML = `
    ${markup}
    ` + inspector.scrollTop = scroll + } + } +} +function scheduleLiveRender() { + if (liveRenderTimer !== 0 || !canReplaceView()) return + liveRenderTimer = window.setTimeout(() => { + liveRenderTimer = 0 + if (canReplaceView()) renderPreservingDetailScroll() + }, 120) +} +async function pollProjection() { + if (polling || document.hidden || !canReplaceView()) return + polling = true + try { + await refreshProjection() + } finally { polling = false } +} +window.setInterval(() => { void pollProjection() }, 1_000) diff --git a/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png b/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png new file mode 100644 index 0000000..13da831 Binary files /dev/null and b/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png differ diff --git a/apps/deepviewer-adapter/assets/synapse/index.html b/apps/deepviewer-adapter/assets/synapse/index.html new file mode 100644 index 0000000..67769b6 --- /dev/null +++ b/apps/deepviewer-adapter/assets/synapse/index.html @@ -0,0 +1 @@ +DeepViewer Conversation Map
    \ No newline at end of file diff --git a/apps/deepviewer-adapter/assets/synapse/styles.css b/apps/deepviewer-adapter/assets/synapse/styles.css new file mode 100644 index 0000000..1c5ce12 --- /dev/null +++ b/apps/deepviewer-adapter/assets/synapse/styles.css @@ -0,0 +1,202 @@ +/* DeepViewer's native adapter supplies the tokens and the shared chat font size. */ +:root { + color-scheme: light; + --map-font-size: 14px; + --map-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; + --map-background: #fff; --map-surface: #f7f7f8; --map-text: #202124; + --map-muted: #80858d; --map-border: #e4e5e8; --map-accent: #457bf3; + --map-hover: #edeef1; --map-subtle: #f3f4f6; --map-error: #d84a45; + font-family: var(--map-font-family); font-size: var(--map-font-size); color: var(--map-text); background: var(--map-background); +} +:root[data-theme="dark"] { + color-scheme: dark; + --map-background: #151515; --map-surface: #232323; --map-text: #eee; + --map-muted: #92969e; --map-border: #363638; --map-accent: #6a9aff; + --map-hover: #303033; --map-subtle: #202023; --map-error: #fa7b73; +} +* { box-sizing: border-box; } +html, body, #app { width: 100%; height: 100%; min-width: 0; min-height: 0; margin: 0; overflow: hidden; } +body { background: var(--map-background); } +button { display: inline-flex; align-items: center; justify-content: center; gap: .3em; min-height: 30px; border: 1px solid var(--map-border); border-radius: 8px; background: transparent; color: var(--map-text); padding: 4px 9px; font: inherit; font-size: .9rem; cursor: pointer; } +button:hover { background: var(--map-hover); } +button:disabled { cursor: default; opacity: .4; } +button:focus-visible { outline: 2px solid var(--map-accent); outline-offset: 2px; } +button.primary { border-color: var(--map-accent); background: var(--map-accent); color: white; } +button svg { width: 1em; height: 1em; flex: none; } +.synapse-shell { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; } +.map-toolbar { display: flex; flex: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; min-width: 0; padding: 8px 12px; border-bottom: 1px solid var(--map-border); background: var(--map-background); } +.canvas-tabs, .canvas-controls { display: flex; position: static; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; } +.canvas-tabs button { border-color: transparent; color: var(--map-muted); } +.canvas-tabs button.active { background: var(--map-subtle); color: var(--map-text); font-weight: 600; } +.canvas-controls { margin-left: auto; } +.canvas-controls button { min-width: 30px; padding-inline: 7px; } +.canvas-controls span { min-width: 44px; text-align: center; color: var(--map-muted); font-size: .86rem; font-variant-numeric: tabular-nums; } +.main-stage { display: flex; position: relative; flex-direction: column; flex: 1; min-width: 0; min-height: 0; overflow: hidden; background: var(--map-background); } +.status-message { display: flex; flex: none; align-items: center; gap: 10px; padding: 8px 12px; background: var(--map-subtle); color: var(--map-error); font-size: .92rem; } +.status-message span { min-width: 0; flex: 1; overflow-wrap: anywhere; } +.status-message button { min-width: 28px; min-height: 28px; border: 0; padding: 0; color: var(--map-muted); } +.canvas-view { display: flex; position: relative; flex: 1; min-width: 0; min-height: 0; } +.canvas-viewport { align-self: flex-start; position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; background: var(--map-background); background-image: radial-gradient(var(--map-border) .7px, transparent .7px); background-size: 20px 20px; cursor: grab; } +.canvas-viewport.is-panning { cursor: grabbing; user-select: none; } +.canvas-viewport.is-panning .canvas-content { will-change: transform; } +.canvas-content { position: absolute; inset: 0; transform-origin: 0 0; } +.connectors { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } +.connectors path { fill: none; stroke: var(--map-border); stroke-width: 1.5; } +.connectors path.active-connector { stroke: var(--map-accent); } +.cards-layer { position: absolute; inset: 0; } +.thread-card { position: absolute; display: grid; width: 310px; height: 276px; grid-template-rows: auto auto minmax(0, 1fr) auto; border: 1px solid var(--map-border); border-radius: 12px; background: var(--map-surface); box-shadow: 0 2px 8px rgb(0 0 0 / 5%); overflow: visible; } +.thread-card:hover { border-color: color-mix(in srgb, var(--map-border), var(--map-muted) 35%); } +/* Browsing is neutral; the blue edge always follows the native composer. */ +.thread-card.selected { border-color: color-mix(in srgb, var(--map-border), var(--map-text) 45%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--map-border), var(--map-text) 45%); } +.thread-card.current { border-color: var(--map-accent); box-shadow: 0 0 0 1px var(--map-accent); } +.node-handle { display: grid; position: absolute; top: -18px; left: 50%; width: 40px; height: 32px; min-height: 32px; padding: 0; transform: translateX(-50%); border: 0; border-radius: 6px; background: transparent; cursor: grab; } +.node-handle:hover { background: transparent; } +.node-handle::after { display: grid; place-items: center; width: 28px; height: 14px; border: 1px solid var(--map-border); border-radius: 5px; background: var(--map-surface); content: '···'; color: var(--map-muted); font-size: .8rem; line-height: 1; letter-spacing: 1px; } +.thread-card-head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 14px 12px 7px; } +.topic-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--map-muted); } +.thread-card.current .topic-dot { background: var(--map-accent); } +.thread-title { min-width: 0; min-height: 22px; flex: 1; justify-content: start; overflow: hidden; border: 0; padding: 0; color: var(--map-text); font-size: 1rem; font-weight: 600; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.thread-title:hover { background: transparent; } +.graph-continue-button, .graph-fold-button, .graph-branch-button { display: grid; position: absolute; z-index: 4; right: -16px; width: 32px; height: 32px; min-height: 32px; padding: 0; border-radius: 50%; border-color: var(--map-border); background: var(--map-surface); color: var(--map-muted); } +.graph-continue-button, .graph-fold-button { top: 50%; transform: translateY(-50%); } +.graph-branch-button { top: calc(50% + 38px); transform: translateY(-50%); } +.graph-continue-button svg, .graph-fold-button svg { fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.8; } +.graph-branch-button svg { fill: currentColor; } +.thread-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 0 12px; color: var(--map-muted); font-size: .78rem; } +.thread-meta span + span::before { content: '·'; margin-right: 5px; } +.thread-meta .card-process-count { margin-left: auto; color: var(--map-accent); } +.thread-meta .card-error-status { color: var(--map-error); } +.thread-answer { min-width: 0; min-height: 0; margin: 9px 12px; overflow-x: hidden; overflow-y: auto; color: var(--map-text); font-size: 1rem; line-height: 1.6; overflow-wrap: anywhere; overscroll-behavior: contain; scrollbar-color: var(--map-muted) transparent; scrollbar-width: thin; } +.thread-answer p { margin: 0 0 8px; } +.thread-answer h1, .thread-answer h2, .thread-answer h3 { margin: 0 0 8px; font-size: 1.05rem; } +.thread-answer ul, .thread-answer ol { margin: 0 0 8px; padding-left: 20px; } +.thread-answer-empty, .thread-answer-pending { color: var(--map-muted); } +.thread-answer-error { color: var(--map-error); overflow-wrap: anywhere; } +.thread-card footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 2px; padding: 6px; border-top: 1px solid var(--map-border); } +.thread-card footer button { min-height: 25px; border: 0; padding: 3px 5px; font-size: .78rem; color: var(--map-text); } +.thread-card footer .compare-button { margin-right: auto; } +.thread-card footer .compare-button.selected { background: var(--map-hover); color: var(--map-accent); } +.selection-followup { display: inline-flex; position: fixed; z-index: 90; min-height: 30px; transform: translateX(-100%); background: var(--map-accent); color: white; border-color: var(--map-accent); box-shadow: 0 2px 8px rgb(0 0 0 / 12%); font-size: .85rem; user-select: none; } +.selection-followup:hover { background: var(--map-accent); } +.selection-followup svg { fill: none; stroke: currentColor; stroke-width: 1.5; } +.selection-followup[hidden] { display: none; } +.card-inspector { position: absolute; z-index: 30; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); min-width: 0; border-left: 1px solid var(--map-border); background: var(--map-background); opacity: 1; transform: translateX(0); transition: transform 180ms ease, opacity 150ms ease; } +.card-inspector.is-opening, .card-inspector.is-closing { opacity: 0; transform: translateX(24px); } +.card-inspector-head { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 12px; align-items: start; padding: 16px 18px 12px; } +.card-inspector-meta { display: flex; flex-wrap: wrap; gap: 6px; color: var(--map-muted); font-size: .85rem; } +.card-inspector-error-status { color: var(--map-error); } +.card-inspector-head h2 { margin: 8px 0 0; overflow-wrap: anywhere; font-size: 1.08rem; line-height: 1.5; } +.card-inspector-close { width: 30px; height: 30px; border: 0; padding: 0; border-radius: 50%; color: var(--map-muted); } +.card-inspector-close svg { fill: none; stroke: currentColor; stroke-width: 1.7; } +.card-inspector-scroll { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow-y: auto; padding: 12px 18px 20px; overscroll-behavior: contain; } +.card-inspector-answer { color: var(--map-text); font-size: 1rem; line-height: 1.75; } +.card-inspector-answer p { margin: 0 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; } +.card-inspector-answer h1, .card-inspector-answer h2, .card-inspector-answer h3 { font-size: 1.08rem; } +.card-inspector-pending { color: var(--map-muted); } +.card-inspector-error { border-left: 3px solid var(--map-error); padding-left: 10px; color: var(--map-error); font-size: 1rem; line-height: 1.6; } +.card-inspector-error p { margin: 5px 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; } +.card-inspector .process-records { margin-top: 20px; } +.card-inspector-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--map-border); } +.card-inspector-actions button { font-size: .85rem; } +.card-inspector-actions svg { fill: none; stroke: currentColor; stroke-width: 1.4; } +.empty-canvas, .compare-empty { display: grid; flex: 1; min-height: 0; align-content: center; justify-items: center; padding: 24px; text-align: center; } +.empty-canvas strong, .compare-empty strong { font-size: 1.05rem; font-weight: 500; } +.empty-canvas p, .compare-empty p { max-width: 460px; color: var(--map-muted); line-height: 1.65; } +.detail-view { display: flex; flex: 1; flex-direction: column; width: min(900px, 100%); min-height: 0; margin: 0 auto; background: var(--map-background); } +.detail-head { display: flex; flex: none; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 12px; padding: 18px 22px 14px; border-bottom: 1px solid var(--map-border); } +.detail-head-title { min-width: 0; flex: 1 1 200px; } +.detail-head-meta { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: .85rem; color: var(--map-muted); } +.detail-subtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.detail-badge { flex: none; } +.detail-head h1 { margin: 8px 0 0; font-size: 1.15rem; line-height: 1.45; overflow-wrap: anywhere; } +.detail-head-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.detail-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 16px 22px 24px; overscroll-behavior: contain; } +.message { margin: 0 0 20px; min-width: 0; font-size: 1rem; } +.message header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--map-muted); font-size: .82rem; } +.message-role { font-weight: 600; color: var(--map-text); } +.message time { margin-right: auto; font-variant-numeric: tabular-nums; } +.message-user { margin-left: auto; width: fit-content; max-width: 88%; border-radius: 12px; background: var(--map-subtle); padding: 12px 16px; } +.message-body { line-height: 1.7; overflow-wrap: anywhere; } +.message-body p { margin: 0 0 10px; white-space: pre-wrap; } +.message-body p:last-child { margin-bottom: 0; } +.message-body h1, .message-body h2, .message-body h3 { margin: 14px 0 8px; font-size: 1.12rem; } +.message-branch, .message-fold { min-height: 25px; border: 0; padding: 2px 5px; font-size: .82rem; color: var(--map-muted); } +.message-branch svg, .message-fold svg { fill: none; stroke: currentColor; stroke-width: 1.4; } +.message-fold svg { transition: transform 140ms ease; } +.message.expanded .message-fold svg { transform: rotate(90deg); } +.message-summary { color: var(--map-muted); } +.message-error, .process-error { color: var(--map-error); } +.message-streaming { color: var(--map-muted); } +.streaming-dot { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: var(--map-accent); animation: map-pulse 1.4s ease-in-out infinite; } +@keyframes map-pulse { 50% { opacity: .3; } } +.process-records { margin-top: 12px; border-top: 1px solid var(--map-border); padding-top: 8px; } +.process-records-fold, .process-entry-fold { display: flex; width: 100%; justify-content: space-between; border: 0; padding: 5px 0; color: var(--map-muted); font-size: .85rem; text-align: left; } +.process-records-fold:hover, .process-entry-fold:hover { background: transparent; color: var(--map-text); } +.process-count { margin-left: auto; } +.process-entry { border-top: 1px solid var(--map-border); padding-top: 6px; } +.process-entry-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; } +.process-entry-body { padding-bottom: 8px; } +.process-status { flex: none; margin-left: 8px; font-size: .8rem; } +.process-status-error { color: var(--map-error); } +.process-status-done { color: var(--map-muted); } +pre { max-width: 100%; overflow-x: auto; border: 1px solid var(--map-border); border-radius: 8px; background: var(--map-subtle); padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: .9rem; } +code { border-radius: 3px; background: var(--map-subtle); padding: 1px 3px; font-family: ui-monospace, SFMono-Regular, monospace; font-size: .9em; } +pre code { background: transparent; padding: 0; } +blockquote { margin-inline: 0; border-left: 3px solid var(--map-border); padding-left: 12px; color: var(--map-muted); } +table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .9rem; } +th, td { border: 1px solid var(--map-border); padding: 5px 8px; text-align: left; } +th { background: var(--map-subtle); } +a { color: var(--map-accent); } +.note-empty { color: var(--map-muted); line-height: 1.6; } +.compare-view { flex: 1; min-height: 0; overflow: auto; padding: 18px; } +.compare-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; width: 100%; max-width: 1100px; margin: 0 auto; } +.compare-columns article { min-width: 0; border: 1px solid var(--map-border); border-radius: 12px; background: var(--map-surface); overflow: hidden; } +.compare-title { display: flex; align-items: center; gap: 8px; padding: 14px; border-bottom: 1px solid var(--map-border); } +.compare-title h2 { min-width: 0; flex: 1; margin: 0; overflow-wrap: anywhere; font-size: 1rem; line-height: 1.5; } +.compare-title button { font-size: .82rem; } +.compare-meta { margin: 10px 14px; font-size: .85rem; color: var(--map-muted); } +.compare-answer { padding: 0 14px 14px; font-size: 1rem; line-height: 1.7; overflow-wrap: anywhere; } +.compare-answer p { margin: 0 0 10px; } +@media (max-width: 640px) { + .map-toolbar { padding: 6px 8px; gap: 4px 8px; } + .canvas-controls { margin-left: 0; } + .card-inspector { width: 100%; top: auto; max-height: 80%; border-top: 1px solid var(--map-border); border-left: 0; } + .card-inspector.is-opening, .card-inspector.is-closing { transform: translateY(24px); } + .detail-head { padding: 14px 14px 10px; } + .detail-scroll { padding: 14px; } + .compare-view { padding: 12px; } + .compare-columns { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +@media (min-width: 641px) { + .canvas-view:has(.card-inspector) > .canvas-viewport { + width: calc(100% - min(460px, 100%)); + height: calc(100% - var(--map-composer-height, 0px)); + flex: none; + } +} + +.card-action-menu { + position: fixed; + z-index: 100; + display: flex; + flex-direction: column; + gap: 4px; + width: 240px; + max-width: calc(100% - 16px); + padding: 6px; + border: 1px solid var(--map-border); + border-radius: 10px; + background: var(--map-surface); + box-shadow: 0 6px 22px rgb(0 0 0 / 14%); +} +.card-action-menu button { justify-content: flex-start; min-height: 36px; border-color: transparent; } +.card-action-menu-hint { margin: 0; padding: 4px 8px; color: var(--map-muted); font-size: .82rem; line-height: 1.5; } +.compose-notice { position: absolute; z-index: 40; top: 8px; left: 8px; max-width: min(560px, calc(100% - 16px)); border: 1px solid var(--map-border); border-radius: 8px; box-shadow: 0 2px 8px rgb(0 0 0 / 8%); display: flex; flex: none; align-items: center; gap: 8px; padding: 8px 12px; color: var(--map-text); background: var(--map-subtle); font-size: .9rem; line-height: 1.5; } +.compose-notice span { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.compose-notice button { flex: none; min-width: 28px; min-height: 28px; border: 0; padding: 0; color: var(--map-muted); } + +@media (min-width: 641px) { + .main-stage:has(.card-inspector) .compose-notice { max-width: calc(100% - min(460px, 100%) - 16px); } +} diff --git a/apps/deepviewer-adapter/client/CodeEditor.tsx b/apps/deepviewer-adapter/client/CodeEditor.tsx new file mode 100644 index 0000000..5a5dab2 --- /dev/null +++ b/apps/deepviewer-adapter/client/CodeEditor.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view' +import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands' +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' +import { bracketMatching, foldGutter, foldKeymap, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' +import { javascript } from '@codemirror/lang-javascript' +import { json } from '@codemirror/lang-json' +import { markdown } from '@codemirror/lang-markdown' +import { python } from '@codemirror/lang-python' +import { html } from '@codemirror/lang-html' +import { css } from '@codemirror/lang-css' + +function language(path: string) { + const extension = path.split('.').at(-1)?.toLowerCase() + if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(extension ?? '')) return javascript({ jsx: extension?.endsWith('x'), typescript: extension?.startsWith('t') }) + if (extension === 'json') return json() + if (extension === 'md') return markdown() + if (extension === 'py') return python() + if (['html', 'htm'].includes(extension ?? '')) return html() + if (extension === 'css') return css() + return [] +} +export function CodeEditor({ path, text, label, onChange, onSave }: { path: string; text: string; label: string; onChange: (text: string) => void; onSave: () => void }) { + const container = useRef(null), editor = useRef(undefined) + const callbacks = useRef({ onChange, onSave }); callbacks.current = { onChange, onSave } + useEffect(() => { + const view = new EditorView({ parent: container.current!, state: EditorState.create({ doc: text, extensions: [ + lineNumbers(), history(), highlightActiveLine(), drawSelection(), bracketMatching(), foldGutter(), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle), language(path), + keymap.of([{ key: 'Mod-s', run: () => { callbacks.current.onSave(); return true } }, ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap, indentWithTab]), + EditorView.contentAttributes.of({ 'aria-label': label, spellcheck: 'false' }), + EditorView.theme({ '&': { height: '100%', color: 'var(--dsw-alias-label-primary)', backgroundColor: 'var(--dsw-alias-bg-base)' }, '.cm-scroller': { overflow: 'auto', fontFamily: 'Menlo, monospace', fontSize: '12px' }, '.cm-gutters': { color: 'var(--dsw-alias-label-tertiary)', background: 'var(--dsw-alias-bg-l1)', borderRight: 'none' }, '.cm-activeLine': { background: 'color-mix(in srgb, currentColor 5%, transparent)' }, '.cm-cursor': { borderLeftColor: 'currentColor' } }), + EditorView.updateListener.of(update => { if (update.docChanged) callbacks.current.onChange(update.state.doc.toString()) }), + ] }) }); editor.current = view + return () => { view.destroy(); editor.current = undefined } + // Document identity creates an editor; ordinary edits preserve selection, undo and scroll. + }, [path, label]) + useEffect(() => { const view = editor.current; if (view && view.state.doc.toString() !== text) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }) }, [text]) + return
    +} diff --git a/apps/deepviewer-adapter/client/DeepViewerMascot.tsx b/apps/deepviewer-adapter/client/DeepViewerMascot.tsx new file mode 100644 index 0000000..b448e3c --- /dev/null +++ b/apps/deepviewer-adapter/client/DeepViewerMascot.tsx @@ -0,0 +1,6 @@ +import css from './welcome.module.css' + +/** Keep the original DeepViewer mark in the native conversation hero slot. */ +export function DeepViewerMascot() { + return