From d18985712c7660bf6ac124a451b05bb856f494ce Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:21:45 +0800 Subject: [PATCH] feat: overhaul chat artifact previews and file change experience (#937) --- electron/main/ipc-handlers.ts | 396 +++++++- electron/preload/index.ts | 6 + package.json | 13 +- pnpm-lock.yaml | 911 ++++++++++++------ src/components/file-preview/ArtifactPanel.tsx | 228 +++++ .../file-preview/FilePreviewBody.tsx | 547 +++++++++++ .../file-preview/FilePreviewOverlay.tsx | 32 + .../file-preview/GeneratedFilesPanel.tsx | 75 ++ src/components/file-preview/ImageViewer.tsx | 58 ++ .../file-preview/MarkdownPreview.tsx | 121 +++ .../file-preview/MonacoDiffViewer.tsx | 86 ++ src/components/file-preview/MonacoViewer.tsx | 76 ++ .../file-preview/PanelResizeDivider.tsx | 107 ++ .../file-preview/SkillFileSections.tsx | 166 ++++ .../file-preview/WorkspaceBrowserBody.tsx | 516 ++++++++++ .../file-preview/build-preview-target.ts | 20 + .../file-preview/file-card-utils.tsx | 39 + src/components/file-preview/format.ts | 12 + src/components/file-preview/types.ts | 43 + .../file-preview/workspace-browser-config.ts | 7 + src/i18n/locales/en/chat.json | 90 +- src/i18n/locales/en/skills.json | 15 +- src/i18n/locales/ja/chat.json | 90 +- src/i18n/locales/ja/skills.json | 15 +- src/i18n/locales/ru/chat.json | 90 +- src/i18n/locales/ru/skills.json | 15 +- src/i18n/locales/zh/chat.json | 90 +- src/i18n/locales/zh/skills.json | 15 +- src/lib/api-client.ts | 97 ++ src/lib/generated-files.ts | 450 +++++++++ src/lib/monaco/loader.ts | 95 ++ src/lib/skill-files.ts | 229 +++++ src/lib/workspace-tree.ts | 124 +++ src/pages/Chat/ChatMessage.tsx | 34 +- src/pages/Chat/ChatToolbar.tsx | 39 +- src/pages/Chat/index.tsx | 157 ++- src/pages/Skills/index.tsx | 258 +---- src/stores/artifact-panel.ts | 91 ++ src/stores/baseline-cache.ts | 98 ++ src/stores/chat.ts | 111 ++- src/stores/chat/history-actions.ts | 27 +- tests/e2e/chat-file-changes.spec.ts | 115 +++ tests/e2e/chat-task-visualizer.spec.ts | 1 - tests/unit/chat-history-actions.test.ts | 31 + tests/unit/chat-page-execution-graph.test.tsx | 61 ++ tests/unit/generated-files-panel.test.tsx | 63 ++ tests/unit/generated-files.test.ts | 154 +++ 47 files changed, 5530 insertions(+), 584 deletions(-) create mode 100644 src/components/file-preview/ArtifactPanel.tsx create mode 100644 src/components/file-preview/FilePreviewBody.tsx create mode 100644 src/components/file-preview/FilePreviewOverlay.tsx create mode 100644 src/components/file-preview/GeneratedFilesPanel.tsx create mode 100644 src/components/file-preview/ImageViewer.tsx create mode 100644 src/components/file-preview/MarkdownPreview.tsx create mode 100644 src/components/file-preview/MonacoDiffViewer.tsx create mode 100644 src/components/file-preview/MonacoViewer.tsx create mode 100644 src/components/file-preview/PanelResizeDivider.tsx create mode 100644 src/components/file-preview/SkillFileSections.tsx create mode 100644 src/components/file-preview/WorkspaceBrowserBody.tsx create mode 100644 src/components/file-preview/build-preview-target.ts create mode 100644 src/components/file-preview/file-card-utils.tsx create mode 100644 src/components/file-preview/format.ts create mode 100644 src/components/file-preview/types.ts create mode 100644 src/components/file-preview/workspace-browser-config.ts create mode 100644 src/lib/generated-files.ts create mode 100644 src/lib/monaco/loader.ts create mode 100644 src/lib/skill-files.ts create mode 100644 src/lib/workspace-tree.ts create mode 100644 src/stores/artifact-panel.ts create mode 100644 src/stores/baseline-cache.ts create mode 100644 tests/e2e/chat-file-changes.spec.ts create mode 100644 tests/unit/generated-files-panel.test.tsx create mode 100644 tests/unit/generated-files.test.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 00937d4..c1909e3 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -5,14 +5,14 @@ import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, extname, basename } from 'node:path'; +import { join, extname, basename, resolve, sep, relative } from 'node:path'; import crypto from 'node:crypto'; import { GatewayManager } from '../gateway/manager'; import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { type ProviderConfig, } from '../utils/secure-storage'; -import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths'; +import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir, expandPath } from '../utils/paths'; import { getOpenClawCliCommand } from '../utils/openclaw-cli'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; import { @@ -138,6 +138,9 @@ export function registerIpcHandlers( // File staging handlers (upload/send separation) registerFileHandlers(); + + // File preview handlers (sandboxed read/write/list for inline viewer) + registerFilePreviewHandlers(); } function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { @@ -2629,3 +2632,392 @@ function registerSessionHandlers(): void { } }); } + +// ── File preview (sandboxed) ────────────────────────────────────────── +// +// IPC channels backing the in-app file preview / overlay components. +// Reads, writes, dir listings and tree scans are restricted to a small +// allowlist of roots so the renderer can never reach arbitrary disk paths +// (defence in depth on top of contextIsolation). + +const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024; // 2 MB +const FILE_PREVIEW_TREE_MAX_DEPTH = 6; +const FILE_PREVIEW_TREE_MAX_NODES = 5000; +const FILE_PREVIEW_DIR_BLACKLIST = new Set([ + 'node_modules', + '.venv', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', +]); + +interface FilePreviewTreeOptions { + maxDepth?: number; + maxNodes?: number; + includeHidden?: boolean; +} + +interface FilePreviewTreeNode { + name: string; + relPath: string; + absPath: string; + isDir: boolean; + size?: number; + mtime?: number; + children?: FilePreviewTreeNode[]; +} + +function isPathInside(child: string, parent: string): boolean { + const c = resolve(child); + const p = resolve(parent); + // Windows file systems are case-insensitive: realpath() returns the + // on-disk casing while `homedir()` / `resolve()` may preserve whatever + // casing the OS reported, leading to false `outsideSandbox` rejections + // (e.g. `C:\Users\Foo\.openclaw\…` vs `c:\users\foo\.openclaw\…`). + // Compare case-insensitively on Windows; keep strict comparison on + // POSIX so we don't accidentally widen the sandbox there. + if (process.platform === 'win32') { + const cl = c.toLowerCase(); + const pl = p.toLowerCase(); + return cl === pl || cl.startsWith(pl + sep); + } + return c === p || c.startsWith(p + sep); +} + +/** + * Roots inside which the file preview pipeline can READ AND WRITE. + * These are the user's own data directories — modifying them is safe. + */ +function getFilePreviewWriteRoots(): string[] { + const roots: string[] = []; + const openclawDir = join(homedir(), '.openclaw'); + roots.push(resolve(openclawDir)); + try { + roots.push(resolve(app.getPath('userData'))); + } catch { + // ignore — userData should always exist + } + roots.push(resolve(OUTBOUND_DIR)); + return roots; +} + +/** + * Additional roots inside which the file preview pipeline can READ ONLY. + * Bundled skills, app resources and the dev-mode project root live here: + * the user has a legitimate reason to peek at SKILL.md / scripts shipped + * with the app, but writing to them would corrupt the install. + */ +function getFilePreviewReadOnlyRoots(): string[] { + const roots: string[] = []; + try { + // In dev this is the project root (covers `node_modules/.pnpm/...`), + // in prod it points at the asar / app dir. + roots.push(resolve(app.getAppPath())); + } catch { + // app.getAppPath() can throw before the app is ready; ignore. + } + if (typeof process.resourcesPath === 'string' && process.resourcesPath) { + roots.push(resolve(process.resourcesPath)); + } + return roots; +} + +interface ResolvedSandboxedPath { + realPath: string; + /** True when the resolved path lives in a read-only-only root (e.g. bundled skill). */ + readOnly: boolean; +} + +async function resolveSandboxedPath( + input: string, + mode: 'read' | 'write' = 'read', +): Promise { + if (typeof input !== 'string' || !input.trim()) { + throw new Error('outsideSandbox'); + } + // OpenClaw stores agent.workspace / agentDir paths as `~/.openclaw/...` + // literals; expand the tilde before realpath so sandbox resolution + // matches what the user actually sees on disk. + const expanded = expandPath(input); + const fsP = await import('fs/promises'); + let real: string; + try { + real = await fsP.realpath(expanded); + } catch { + // Path may not exist yet (e.g. write that should fail later); + // resolve without realpath fallback so the sandbox check is still applied. + real = resolve(expanded); + } + const writeRoots = getFilePreviewWriteRoots(); + if (writeRoots.some((root) => isPathInside(real, root))) { + return { realPath: real, readOnly: false }; + } + if (mode === 'write') { + // Caller wants to mutate. If the path is inside a read-only root we can + // surface a more informative error; otherwise it's a true sandbox miss. + const roRoots = getFilePreviewReadOnlyRoots(); + if (roRoots.some((root) => isPathInside(real, root))) { + throw new Error('readOnlyRoot'); + } + throw new Error('outsideSandbox'); + } + const roRoots = getFilePreviewReadOnlyRoots(); + if (roRoots.some((root) => isPathInside(real, root))) { + return { realPath: real, readOnly: true }; + } + throw new Error('outsideSandbox'); +} + +function looksLikeBinary(buf: Buffer): boolean { + // Treat presence of a NUL byte in the first 8 KB as binary, matching + // the heuristic used by isbinaryfile / git. + const limit = Math.min(buf.length, 8192); + for (let i = 0; i < limit; i += 1) { + if (buf[i] === 0) return true; + } + return false; +} + +function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean { + if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true; + if (!includeHidden && name.startsWith('.')) return true; + return false; +} + +function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean { + if (!includeHidden && name.startsWith('.')) return true; + return false; +} + +function registerFilePreviewHandlers(): void { + ipcMain.handle('file:readText', async (_, inputPath: string) => { + try { + const { realPath: real, readOnly } = await resolveSandboxedPath(inputPath, 'read'); + const fsP = await import('fs/promises'); + const stat = await fsP.stat(real); + if (!stat.isFile()) { + return { ok: false, error: 'notFound' }; + } + if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) { + return { ok: false, error: 'tooLarge', size: stat.size }; + } + const buf = await fsP.readFile(real); + if (looksLikeBinary(buf)) { + return { ok: false, error: 'binary', size: stat.size }; + } + return { + ok: true, + content: buf.toString('utf8'), + mimeType: getMimeType(extname(real)), + size: stat.size, + readOnly, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') { + return { ok: false, error: 'outsideSandbox' }; + } + if (message.includes('ENOENT')) { + return { ok: false, error: 'notFound' }; + } + return { ok: false, error: message }; + } + }); + + ipcMain.handle('file:writeText', async (_, inputPath: string, content: string) => { + try { + if (typeof content !== 'string') { + return { ok: false, error: 'invalidContent' }; + } + if (Buffer.byteLength(content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) { + return { ok: false, error: 'tooLarge' }; + } + const { realPath: real } = await resolveSandboxedPath(inputPath, 'write'); + const fsP = await import('fs/promises'); + // Only allow writing existing files to avoid surprise creation. + let stat; + try { + stat = await fsP.stat(real); + } catch { + return { ok: false, error: 'notFound' }; + } + if (!stat.isFile()) { + return { ok: false, error: 'notFound' }; + } + await fsP.writeFile(real, content, 'utf8'); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') { + return { ok: false, error: 'outsideSandbox' }; + } + if (message === 'readOnlyRoot') { + return { ok: false, error: 'readOnlyRoot' }; + } + return { ok: false, error: message }; + } + }); + + ipcMain.handle('file:stat', async (_, inputPath: string) => { + try { + const { realPath: real, readOnly } = await resolveSandboxedPath(inputPath, 'read'); + const fsP = await import('fs/promises'); + const stat = await fsP.stat(real); + return { + ok: true, + size: stat.size, + mtime: stat.mtimeMs, + isFile: stat.isFile(), + isDir: stat.isDirectory(), + readOnly, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') { + return { ok: false, error: 'outsideSandbox' }; + } + if (message.includes('ENOENT')) { + return { ok: false, error: 'notFound' }; + } + return { ok: false, error: message }; + } + }); + + ipcMain.handle('file:listDir', async (_, inputPath: string) => { + try { + const { realPath: real } = await resolveSandboxedPath(inputPath, 'read'); + const fsP = await import('fs/promises'); + const dirents = await fsP.readdir(real, { withFileTypes: true }); + const entries = await Promise.all(dirents.map(async (entry) => { + const abs = join(real, entry.name); + let size = 0; + try { + if (entry.isFile()) { + size = (await fsP.stat(abs)).size; + } + } catch { + // non-fatal + } + return { + name: entry.name, + path: abs, + isDir: entry.isDirectory(), + size, + }; + })); + return { ok: true, entries }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') { + return { ok: false, error: 'outsideSandbox' }; + } + if (message.includes('ENOENT')) { + return { ok: false, error: 'notFound' }; + } + return { ok: false, error: message }; + } + }); + + ipcMain.handle('file:listTree', async (_, inputPath: string, opts?: FilePreviewTreeOptions) => { + try { + const { realPath: real } = await resolveSandboxedPath(inputPath, 'read'); + const fsP = await import('fs/promises'); + const stat = await fsP.stat(real); + if (!stat.isDirectory()) { + return { ok: false, error: 'notDirectory' }; + } + const maxDepth = Math.max(1, Math.min(opts?.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12)); + const maxNodes = Math.max(1, Math.min(opts?.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000)); + const includeHidden = !!opts?.includeHidden; + + let nodeCount = 0; + let truncated = false; + + const walk = async ( + absDir: string, + depth: number, + ): Promise => { + if (depth > maxDepth || truncated) return undefined; + let dirents; + try { + dirents = await fsP.readdir(absDir, { withFileTypes: true }); + } catch { + return []; + } + const children: FilePreviewTreeNode[] = []; + for (const entry of dirents) { + if (truncated) break; + const isDir = entry.isDirectory(); + const isFile = entry.isFile(); + if (!isDir && !isFile) continue; + if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue; + if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue; + if (nodeCount >= maxNodes) { + truncated = true; + break; + } + nodeCount += 1; + const abs = join(absDir, entry.name); + // Normalise relPath to forward slashes for renderer use — the + // renderer derives the same value cross-platform when looking + // up a node by path, and Windows backslashes look out of place + // in URLs / display strings. + const rel = relative(real, abs).split(sep).join('/'); + const node: FilePreviewTreeNode = { + name: entry.name, + relPath: rel, + absPath: abs, + isDir, + }; + if (isFile) { + try { + const fstat = await fsP.stat(abs); + node.size = fstat.size; + node.mtime = fstat.mtimeMs; + } catch { + // non-fatal + } + } else if (isDir) { + try { + const fstat = await fsP.stat(abs); + node.mtime = fstat.mtimeMs; + } catch { + // non-fatal + } + node.children = await walk(abs, depth + 1) ?? []; + } + children.push(node); + } + children.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return children; + }; + + const root: FilePreviewTreeNode = { + name: basename(real) || real, + relPath: '', + absPath: real, + isDir: true, + mtime: stat.mtimeMs, + children: (await walk(real, 1)) ?? [], + }; + + return { ok: true, root, truncated }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') { + return { ok: false, error: 'outsideSandbox' }; + } + if (message.includes('ENOENT')) { + return { ok: false, error: 'notFound' }; + } + return { ok: false, error: message }; + } + }); +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 60dcab8..1335d28 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -128,6 +128,12 @@ const electronAPI = { 'file:stageBuffer', 'media:getThumbnails', 'media:saveImage', + // File preview (sandboxed read/write/list/tree) + 'file:readText', + 'file:writeText', + 'file:stat', + 'file:listDir', + 'file:listTree', // Chat send with media (reads staged files in main process) 'chat:sendWithMedia', // Session management diff --git a/package.json b/package.json index 68240fd..edc1449 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs", "dev": "vite", "ext:bridge": "node scripts/generate-ext-bridge.mjs", - "build": "node scripts/generate-ext-bridge.mjs && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", - "build:vite": "vite build", + "build": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", + "build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs", "bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs", "lint": "eslint . --fix", @@ -56,7 +56,7 @@ "node:download:win": "zx scripts/download-bundled-node.mjs --platform=win", "prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win", "icons": "zx scripts/generate-icons.mjs", - "package": "node scripts/generate-ext-bridge.mjs && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs", + "package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs", "package:mac": "pnpm run package && electron-builder --mac --publish never", "package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && electron-builder --mac --publish never", "package:win": "pnpm run prep:win-binaries && pnpm run package && electron-builder --win --publish never", @@ -71,17 +71,22 @@ "postversion": "git push && git push --tags" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "@sinclair/typebox": "^0.34.48", + "@types/diff": "^8.0.0", "chokidar": "^5.0.0", "clawhub": "^0.5.0", + "diff": "^9.0.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", "katex": "^0.16.45", "lru-cache": "^11.2.6", + "monaco-editor": "^0.55.1", "ms": "^2.1.3", "node-machine-id": "^1.1.12", "posthog-node": "^5.28.0", "rehype-katex": "^7.0.1", + "remark-frontmatter": "^5.0.0", "remark-math": "^6.0.0", "tar": "^6.2.1", "ws": "^8.19.0" @@ -169,4 +174,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3cd7f7..0e12ad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,24 @@ importers: .: dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.48 + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 chokidar: specifier: ^5.0.0 version: 5.0.0 clawhub: specifier: ^0.5.0 version: 0.5.0 + diff: + specifier: ^9.0.0 + version: 9.0.0 electron-store: specifier: ^11.0.2 version: 11.0.2 @@ -32,6 +41,9 @@ importers: lru-cache: specifier: ^11.2.6 version: 11.2.7 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 ms: specifier: ^2.1.3 version: 2.1.3 @@ -44,6 +56,9 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 remark-math: specifier: ^6.0.0 version: 6.0.0 @@ -74,10 +89,10 @@ importers: version: 1.3.7 '@larksuite/openclaw-lark': specifier: 2026.4.8 - version: 2026.4.8(openclaw@2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13)) + version: 2026.4.8(openclaw@2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13)) '@larksuiteoapi/node-sdk': specifier: ^1.61.1 - version: 1.62.1 + version: 1.62.0 '@playwright/test': specifier: ^1.56.1 version: 1.59.0 @@ -119,13 +134,13 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@soimy/dingtalk': specifier: ^3.5.3 - version: 3.5.3(openclaw@2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13)) + version: 3.5.3(openclaw@2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13)) '@tencent-connect/qqbot-connector': specifier: ^1.1.0 version: 1.1.0 '@tencent-weixin/openclaw-weixin': specifier: ^2.1.9 - version: 2.3.1 + version: 2.1.9 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -155,7 +170,7 @@ importers: version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)) '@wecom/wecom-openclaw-plugin': specifier: ^2026.4.23 - version: 2026.4.29 + version: 2026.4.2201 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(jimp@1.6.1)(sharp@0.34.5) @@ -215,7 +230,7 @@ importers: version: 1.0.3 openclaw: specifier: 2026.4.23 - version: 2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13) + version: 2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13) opusscript: specifier: ^0.1.1 version: 0.1.1 @@ -320,15 +335,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/sdk@0.80.0': - resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@anthropic-ai/sdk@0.90.0': resolution: {integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==} hasBin: true @@ -374,44 +380,44 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1039.0': - resolution: {integrity: sha512-rpm9rGcv95ulprNIu/ruhreG4bSKq7oFrErM1Nkp9Cq/zzo/11Hw1/ffYKLM/PAcMGZ+5/zAHOCWBDQ3W1lIBw==} + '@aws-sdk/client-bedrock-runtime@3.1035.0': + resolution: {integrity: sha512-XELIQk+znh53J2Bj0EmOftgcKRLw3tvI/P4WHLgSbSBWPh+wg0SvHu+bgIlzMHARbOC9auA0lsH+Eb9JwF1yjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.7': - resolution: {integrity: sha512-YhRC90ofz5oolTJZlA8voU/oUrCB2azi8Usx51k8hhB5LpWbYQMMXKUqSqkoL0Cru+RQJgWTHpAfEDDIwfUhJw==} + '@aws-sdk/core@3.974.4': + resolution: {integrity: sha512-EbVgyzQ83/Lf6oh1O4vYY47tuYw3Aosthh865LNU77KyotKz+uvEBNmsl/bSVS/vG+IU39mCqcOHrnhmhF4lug==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.33': - resolution: {integrity: sha512-bJV7eViSJV6GSuuN+VIdNVPdwPsNSf75BiC2v5alPrjR/OCcqgKwSZInKbDFz9mNeizldsyf67jt6YSIiv53Cw==} + '@aws-sdk/credential-provider-env@3.972.30': + resolution: {integrity: sha512-dHpeqa29a0cBYq/h59IC2EK3AphLY96nKy4F35kBtiz9GuKDc32UYRTgjZaF8uuJCnqgw9omUZKR+9myyDHC2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.35': - resolution: {integrity: sha512-x/BQGEIdq0oI+4WxLjKmnQvT7CnF9r8ezdGt7wXwxb7ckHXQz0Zmgxt8v3Ne0JaT3R5YefmuybHX6E8EnsDXyA==} + '@aws-sdk/credential-provider-http@3.972.32': + resolution: {integrity: sha512-A+ZTT//Mswkf9DFEM6XlngwOtYdD8X4CUcoZ2wdpgI8cCs9mcGeuhgTwbGJvealub/MeONOaUr3FbRPMKmTDjg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.37': - resolution: {integrity: sha512-eUTpmWfd/BKsq9medhCRcu+GRAhFP2Zrn7/2jKDHHOOjCkhrMoTp/t4cEthqFoG7gE0VGp5wUxrXTdvBCmSmJg==} + '@aws-sdk/credential-provider-ini@3.972.34': + resolution: {integrity: sha512-MoRc7tLnx3JpFkV2R826enEfBUVN8o9Cc7y3hnbMwiWzL/VJhgfxRQzHkEL9vWorMWP7tibltsRcLoid9fsVdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.37': - resolution: {integrity: sha512-Ty68y8ISSC+g5Q3D0K8uAaoINwvfaOslnNpsF/LgVUxyosYXHawcK2yV4HLXDVugiTTYLQfJfcw0ce5meAGkKw==} + '@aws-sdk/credential-provider-login@3.972.34': + resolution: {integrity: sha512-XVSklkRRQ/CQDmv3VVFdZRl5hTFgncFhZrLyi0Ai4LZk5o3jpY5HIfuTK7ad7tixPKa+iQmL9+vg9qNyYZB+nw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.38': - resolution: {integrity: sha512-BQ9XYnBDVxR2HuV5huXYQYF/PZMTsY+EnwfGnCU2cA8Zw63XpkOtPY8WqiMIZMQCrKPQQEiFURS/o9CIolRLqg==} + '@aws-sdk/credential-provider-node@3.972.35': + resolution: {integrity: sha512-nVrY7AdGfzYgAa/jd9m06p3ES7QQDaB7zN9c+vXnVXxBRkAs9MjRDPB5AKogWuC6phddltfvHGFqLDJmyU9u/A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.33': - resolution: {integrity: sha512-yfjGksI9WQbdMObb0VeLXqzTLI+a0qXLJT9gCDiv0+X/xjPpI3mTz6a5FibrhpuEKIe0gSgvs3MaoFZy5cx4WA==} + '@aws-sdk/credential-provider-process@3.972.30': + resolution: {integrity: sha512-McJPomNTSEo+C6UA3Zq6pFrcyTUaVsoPPBOvbOHAoIFPc8Z2CMLndqFJOnB+9bVFiBTWQLutlVGmrocBbvv4MQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.37': - resolution: {integrity: sha512-fpwE+20ntpp3i9Xb9vUuQfXLDKYHH+5I2V+ZG96SX1nBzrruhy10RXDgmN7t1etOz3c55stlA3TeQASUA451NQ==} + '@aws-sdk/credential-provider-sso@3.972.34': + resolution: {integrity: sha512-WngYb2K+/yhkDOmDfAOjoCa9Ja3he0DZiAraboKwgWoVRkajDIcDYBCVbUTxtTUldvQoe7VvHLTrBNxvftN1aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.37': - resolution: {integrity: sha512-aryawqyebf+3WhAFNHfF62rekFpYtVcVN7dQ89qnAWsa4n5hJst8qBG6gXC24WHtW7Nnhkf9ScYnjwo0Brn3bw==} + '@aws-sdk/credential-provider-web-identity@3.972.34': + resolution: {integrity: sha512-5KLUH+XmSNRj6amJiJSrPsCxU5l/PYDfxyqPa1MxWhHoQC3sxvGPrSib3IE+HQlfRA4e2kO0bnJy7HJdjvpuuA==} engines: {node: '>=20.0.0'} '@aws-sdk/eventstream-handler-node@3.972.14': @@ -434,32 +440,32 @@ packages: resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.36': - resolution: {integrity: sha512-YhPix+0x/MdQrb1Ug1GDKeS5fqylIy+naz800asX8II4jqfTk2KY2KhmmYCwZcky8YWtRQQwWCGdoqeAnip8Uw==} + '@aws-sdk/middleware-sdk-s3@3.972.33': + resolution: {integrity: sha512-n8Eh/+kq3u/EodLr8n6sQupu03QGjf122RHXCTGLaHSkavz/2beSKpRlq2oDgfmJZNkAkWF113xbyaUmyOd+YA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.37': - resolution: {integrity: sha512-N1oNpdiLoVAWYD3WFBnUi3LlfoDA06ZHo4ozyjbsJNLvILzvt//0CnR8N+CZ0NWeYgVB/5V59ivixHCWCx2ALw==} + '@aws-sdk/middleware-user-agent@3.972.34': + resolution: {integrity: sha512-jrmJHyYlTQocR7H4VhvSFhaoedMb2rmlOTvFWD6tNBQ/EVQhTsrNfQUYFuPiOc2wUGxbm5LgCHtnvVmCPgODHw==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-websocket@3.972.16': resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.5': - resolution: {integrity: sha512-jGFr6DxtcMTmzOkG/a0jCZYv4BBDmeNYVeO+/memSoDkYCJu4Y58xviYmzwJfYyIVSts+X/BVjJm1uGBnwHEMg==} + '@aws-sdk/nested-clients@3.997.2': + resolution: {integrity: sha512-uGGQO08YetrqfInOKG5atRMrCDRQWRuZ9gGfKY6svPmuE4K7ac+XcbCkpWpjcA7yCYsBaKB/Nly4XKgPXUO1PA==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.972.13': resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.24': - resolution: {integrity: sha512-amP7tLikppN940wbBFISYqiuzVmpzMS9U3mcgtmVLjX4fdWI/SNCvrXv6ZxfVzTT4cT0rPKOLhFah2xLwzREWw==} + '@aws-sdk/signature-v4-multi-region@3.996.21': + resolution: {integrity: sha512-3EpT+C0QdmTMB5aVeJ5odWSLt9vg2oGzUXl1xvUazKGlkr9OBYnegNWqhhjGgZdv8RmSi5eS8nqqB+euNP2aqA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1039.0': - resolution: {integrity: sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw==} + '@aws-sdk/token-providers@3.1035.0': + resolution: {integrity: sha512-E6IO3Cn+OzBe6Sb5pnubd5Y8qSUMAsVKkD5QSwFfIx5fV1g5SkYwUDRDyPlm90RuIVcCo28wpMJU6W8wXH46Aw==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.8': @@ -485,8 +491,8 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.10': resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} - '@aws-sdk/util-user-agent-node@3.973.23': - resolution: {integrity: sha512-gGwq8L2Euw0aNG6Ey4EktiAo3fSCVoDy1CaBIthd+oeaKHPXUrNaApMewQ6La5Hv0lcznOtECZaNvYyc5LXXfA==} + '@aws-sdk/util-user-agent-node@3.973.20': + resolution: {integrity: sha512-owEqyKr0z5hWwk+uHwudwNhyFMZ9f9eSWr/k/XD6yeDCI7hHyc56s4UOY1iBQmoramTbdAY4UCuLLEuKmjVXrg==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -494,8 +500,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.22': - resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + '@aws-sdk/xml-builder@3.972.18': + resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -1303,8 +1309,8 @@ packages: openclaw: optional: true - '@larksuiteoapi/node-sdk@1.62.1': - resolution: {integrity: sha512-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg==} + '@larksuiteoapi/node-sdk@1.62.0': + resolution: {integrity: sha512-ZITiuAkiVgphn6OPO8MHeWV1q7+UNByLmNiYVDIAxF5+HJ8USl4xPinDOq9AMJSEUqdBJtiLdz7UltV5jP+EDg==} '@lydell/node-pty-darwin-arm64@1.2.0-beta.12': resolution: {integrity: sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==} @@ -1347,72 +1353,72 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mariozechner/clipboard-darwin-arm64@0.3.2': - resolution: {integrity: sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==} + '@mariozechner/clipboard-darwin-arm64@0.3.3': + resolution: {integrity: sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@mariozechner/clipboard-darwin-universal@0.3.2': - resolution: {integrity: sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==} + '@mariozechner/clipboard-darwin-universal@0.3.3': + resolution: {integrity: sha512-x9aRfTyndVqpEQ44LNNCK/EXZd9y8rWkLQgNhmWpby9PXrjPhNxfjUc2Db4mt4nJjU/4zzO8F5v/XyzlUGSdhQ==} engines: {node: '>= 10'} os: [darwin] - '@mariozechner/clipboard-darwin-x64@0.3.2': - resolution: {integrity: sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==} + '@mariozechner/clipboard-darwin-x64@0.3.3': + resolution: {integrity: sha512-6ut/NawB0KiYPCwrirgNp6Br62LntL978q7G6d/Rs2pmPvQb53bP96eUMYl+Y3a7Qk13bGZ4w9rVPFxRE9m9ag==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@mariozechner/clipboard-linux-arm64-gnu@0.3.2': - resolution: {integrity: sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==} + '@mariozechner/clipboard-linux-arm64-gnu@0.3.3': + resolution: {integrity: sha512-gf3dH4kBddU1AOyHVB53mjLUFfJAKlTmxTMw51jdeg7eE7IjfEBXVvM4bifMtBxbWkT0eA0FUZ1C0KQ6Z5l6pw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-arm64-musl@0.3.2': - resolution: {integrity: sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==} + '@mariozechner/clipboard-linux-arm64-musl@0.3.3': + resolution: {integrity: sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.2': - resolution: {integrity: sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==} + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.3': + resolution: {integrity: sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-gnu@0.3.2': - resolution: {integrity: sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==} + '@mariozechner/clipboard-linux-x64-gnu@0.3.3': + resolution: {integrity: sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-musl@0.3.2': - resolution: {integrity: sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==} + '@mariozechner/clipboard-linux-x64-musl@0.3.3': + resolution: {integrity: sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@mariozechner/clipboard-win32-arm64-msvc@0.3.2': - resolution: {integrity: sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==} + '@mariozechner/clipboard-win32-arm64-msvc@0.3.3': + resolution: {integrity: sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@mariozechner/clipboard-win32-x64-msvc@0.3.2': - resolution: {integrity: sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==} + '@mariozechner/clipboard-win32-x64-msvc@0.3.3': + resolution: {integrity: sha512-5hvaEq/bgYovTIGx43O/S7loIHYV3ue90WcV1dz0wdMXroVKZKeU/yfwM0PALQA1OcrEHiGXGySFReXr72lGtA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@mariozechner/clipboard@0.3.2': - resolution: {integrity: sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==} + '@mariozechner/clipboard@0.3.3': + resolution: {integrity: sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==} engines: {node: '>= 10'} '@mariozechner/jiti@2.6.5': @@ -1423,11 +1429,20 @@ packages: resolution: {integrity: sha512-ZwfM5QPvSwza/apZhPIXjrI/blJBFqbVpK30ma4zNwH8VAyseKlzGDExCx/k+81Xydg60sQuG2BQVkYGmofuSg==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-agent-core@0.70.2': + resolution: {integrity: sha512-g1hIdKyDwmQOoBGO0R4OhpemKeMENeK0vE5FJtuQKqEcsdCAkVBgZAK6aZUARYZVxMA718JS6WPLFWoddzjD7g==} + engines: {node: '>=20.0.0'} + '@mariozechner/pi-ai@0.70.0': resolution: {integrity: sha512-lVT9bb0eFkNr5YXvZ5r00TNA5r110fOO8uJV9VLCQ5GdtunWIjcptWitzIjjl2MF0/NDs7Kb2EwZctXQWWP7eA==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-ai@0.70.2': + resolution: {integrity: sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-coding-agent@0.70.0': resolution: {integrity: sha512-Sw5odG9BYIcRItb/o4Gmq0nSIgoWfx61Isjk3Gk4KqocxHZAOwZZYQ4mgb4GCsevqOMmAzX/H6PC52/TiN76fw==} engines: {node: '>=20.6.0'} @@ -1437,6 +1452,10 @@ packages: resolution: {integrity: sha512-x/CwIMP8v9KNrmgEFA0+AWIwSWeNAitEI4eVQtQ6q2a0PpE+vx1+j2oc+iDPe7E1YqrMHXaNlHJVCaVAv/UYrg==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.70.2': + resolution: {integrity: sha512-PtKC0NepnrYcqMx6MXkWTrBzC9tI62KeC6w940oT46lCbfvgmfqXciR15+9BZpxxc1H4jd3CMrKsmOPVeUqZ0A==} + engines: {node: '>=20.0.0'} + '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} @@ -1450,10 +1469,26 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@mozilla/readability@0.6.0': resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-android-arm64@0.1.80': resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} engines: {node: '>= 10'} @@ -1466,6 +1501,12 @@ packages: cpu: [arm64] os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-arm64@0.1.80': resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} engines: {node: '>= 10'} @@ -1478,6 +1519,12 @@ packages: cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.80': resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} engines: {node: '>= 10'} @@ -1490,6 +1537,12 @@ packages: cpu: [x64] os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} engines: {node: '>= 10'} @@ -1502,6 +1555,13 @@ packages: cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} engines: {node: '>= 10'} @@ -1516,6 +1576,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} @@ -1530,6 +1597,13 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} @@ -1544,6 +1618,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} @@ -1558,6 +1639,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} @@ -1572,12 +1660,24 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} engines: {node: '>= 10'} @@ -1590,6 +1690,10 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/canvas@0.1.80': resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} engines: {node: '>= 10'} @@ -1608,9 +1712,6 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2283,8 +2384,8 @@ packages: resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.17': - resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + '@smithy/core@3.23.16': + resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.14': @@ -2335,16 +2436,16 @@ packages: resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.32': - resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + '@smithy/middleware-endpoint@4.4.31': + resolution: {integrity: sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.5.7': - resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + '@smithy/middleware-retry@4.5.4': + resolution: {integrity: sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.20': - resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + '@smithy/middleware-serde@4.2.19': + resolution: {integrity: sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==} engines: {node: '>=18.0.0'} '@smithy/middleware-stack@4.2.14': @@ -2355,8 +2456,8 @@ packages: resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.6.1': - resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + '@smithy/node-http-handler@4.6.0': + resolution: {integrity: sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.2.14': @@ -2375,8 +2476,8 @@ packages: resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.3.1': - resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + '@smithy/service-error-classification@4.3.0': + resolution: {integrity: sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==} engines: {node: '>=18.0.0'} '@smithy/shared-ini-file-loader@4.4.9': @@ -2387,8 +2488,8 @@ packages: resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.13': - resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + '@smithy/smithy-client@4.12.12': + resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} engines: {node: '>=18.0.0'} '@smithy/types@4.14.1': @@ -2423,12 +2524,12 @@ packages: resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.49': - resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + '@smithy/util-defaults-mode-browser@4.3.48': + resolution: {integrity: sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.54': - resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + '@smithy/util-defaults-mode-node@4.2.53': + resolution: {integrity: sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.4.2': @@ -2443,12 +2544,12 @@ packages: resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.3.6': - resolution: {integrity: sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==} + '@smithy/util-retry@4.3.3': + resolution: {integrity: sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.25': - resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} + '@smithy/util-stream@4.5.24': + resolution: {integrity: sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.2': @@ -2577,8 +2678,8 @@ packages: resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==} engines: {node: '>=18.0.0'} - '@tencent-weixin/openclaw-weixin@2.3.1': - resolution: {integrity: sha512-plOrZq5Oj4YkmP2/yclVnlPuPruUfXxk7BmHfnJFcFPmaXpt9X3gMF1WyQ6ebnEqtCvIpAIsntufgKShZemxCA==} + '@tencent-weixin/openclaw-weixin@2.1.9': + resolution: {integrity: sha512-5dLvKCFebSMRNQ2l0Lqx78gyuL0Msdg1NVmcN6fuxtZiaQiOx4IkIl8+9WPN+e26jR8XM5qIfW2sgf7pe7M1ew==} engines: {node: '>=22'} '@testing-library/dom@10.4.1': @@ -2647,6 +2748,10 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@8.0.0': + resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} + deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -2718,6 +2823,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2841,8 +2949,8 @@ packages: '@wecom/aibot-node-sdk@1.0.6': resolution: {integrity: sha512-WZJN3Q+s+94Qjc0VW8d5W1cVkA3emYxiqf+mNRO9UEHoF40puHvizreNMtudjFhm7mmkYiK5ue/QzNiCk+xwLA==} - '@wecom/wecom-openclaw-plugin@2026.4.29': - resolution: {integrity: sha512-t6IFMG991ZYSzctJ+FoLdZ4uZeJnL/WVII4F56oUM1UDx9Ryy/bC+AiEffVKYYZ0bQvGkfha/kswvo4W2Q4g+g==} + '@wecom/wecom-openclaw-plugin@2026.4.2201': + resolution: {integrity: sha512-bbRGmijtMyflxLLkijeESnKu3pIOjetT9UPChdNfweanCo9l1I5fBfVerV016/FyY7k71RfUyvPiUAsChaGXvw==} '@whiskeysockets/baileys@7.0.0-rc.9': resolution: {integrity: sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==} @@ -3084,8 +3192,8 @@ packages: bare-path@3.0.0: resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - bare-stream@2.13.1: - resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + bare-stream@2.13.0: + resolution: {integrity: sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==} peerDependencies: bare-abort-controller: '*' bare-buffer: '*' @@ -3112,6 +3220,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3563,6 +3672,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -3609,6 +3722,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3911,16 +4027,23 @@ packages: fast-wrap-ansi@0.1.6: resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.7.2: - resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + fast-xml-parser@5.5.10: + resolution: {integrity: sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==} + hasBin: true + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -3995,6 +4118,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4705,6 +4832,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -4724,6 +4856,9 @@ packages: mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -4787,6 +4922,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -4993,6 +5131,9 @@ packages: engines: {node: '>=10'} hasBin: true + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + motion-dom@12.38.0: resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} @@ -5064,9 +5205,6 @@ packages: node-machine-id@1.1.12: resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} - node-readable-to-web-readable-stream@0.4.2: - resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} - node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -5290,8 +5428,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + path-expression-matcher@1.2.1: + resolution: {integrity: sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==} engines: {node: '>=14.0.0'} path-is-absolute@1.0.1: @@ -5328,9 +5466,9 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} - pdfjs-dist@5.6.205: - resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} - engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + pdfjs-dist@5.7.284: + resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + engines: {node: '>=22.13.0 || >=24'} pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} @@ -5715,6 +5853,9 @@ packages: rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -6020,6 +6161,9 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -6070,8 +6214,8 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - strnum@2.2.3: - resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} @@ -6126,8 +6270,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tar-stream@3.2.0: - resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar-stream@3.1.8: + resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} @@ -6273,6 +6417,9 @@ packages: typebox@1.1.28: resolution: {integrity: sha512-OqHTHRfBpQHWPipoeFVkNgxKjjXhGY49UgekFrOuaC9O59/Hws8KHjGa1AUfNYnxWSfuNRkTB81FAL/QbTWFrg==} + typebox@1.1.33: + resolution: {integrity: sha512-+/MWwlQ1q2GSVwoxi/+u5JsHkgLQKcCN2Nsjree9c+K7GJu40qbaHrFETmfV1i9Fs1TcOVfynW+jJvIWcXtvjw==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -6406,6 +6553,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true vary@1.1.2: @@ -6746,12 +6894,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@anthropic-ai/sdk@0.90.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -6760,7 +6902,7 @@ snapshots: '@anthropic-ai/vertex-sdk@0.16.0(encoding@0.1.13)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.80.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) google-auth-library: 9.15.1(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -6823,27 +6965,27 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1039.0': + '@aws-sdk/client-bedrock-runtime@3.1035.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.7 - '@aws-sdk/credential-provider-node': 3.972.38 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/credential-provider-node': 3.972.35 '@aws-sdk/eventstream-handler-node': 3.972.14 '@aws-sdk/middleware-eventstream': 3.972.10 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.37 + '@aws-sdk/middleware-user-agent': 3.972.34 '@aws-sdk/middleware-websocket': 3.972.16 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/token-providers': 3.1039.0 + '@aws-sdk/token-providers': 3.1035.0 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.23 + '@aws-sdk/util-user-agent-node': 3.973.20 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/eventstream-serde-browser': 4.2.14 '@smithy/eventstream-serde-config-resolver': 4.3.14 '@smithy/eventstream-serde-node': 4.2.14 @@ -6851,78 +6993,78 @@ snapshots: '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.7 - '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-endpoint': 4.4.31 + '@smithy/middleware-retry': 4.5.4 + '@smithy/middleware-serde': 4.2.19 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 + '@smithy/node-http-handler': 4.6.0 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-defaults-mode-browser': 4.3.48 + '@smithy/util-defaults-mode-node': 4.2.53 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/util-stream': 4.5.25 + '@smithy/util-retry': 4.3.3 + '@smithy/util-stream': 4.5.24 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.974.7': + '@aws-sdk/core@3.974.4': dependencies: '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.22 - '@smithy/core': 3.23.17 + '@aws-sdk/xml-builder': 3.972.18 + '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.3 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.33': + '@aws-sdk/credential-provider-env@3.972.30': dependencies: - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.35': + '@aws-sdk/credential-provider-http@3.972.32': dependencies: - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/types': 3.973.8 '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 + '@smithy/node-http-handler': 4.6.0 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 + '@smithy/util-stream': 4.5.24 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.37': + '@aws-sdk/credential-provider-ini@3.972.34': dependencies: - '@aws-sdk/core': 3.974.7 - '@aws-sdk/credential-provider-env': 3.972.33 - '@aws-sdk/credential-provider-http': 3.972.35 - '@aws-sdk/credential-provider-login': 3.972.37 - '@aws-sdk/credential-provider-process': 3.972.33 - '@aws-sdk/credential-provider-sso': 3.972.37 - '@aws-sdk/credential-provider-web-identity': 3.972.37 - '@aws-sdk/nested-clients': 3.997.5 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/credential-provider-env': 3.972.30 + '@aws-sdk/credential-provider-http': 3.972.32 + '@aws-sdk/credential-provider-login': 3.972.34 + '@aws-sdk/credential-provider-process': 3.972.30 + '@aws-sdk/credential-provider-sso': 3.972.34 + '@aws-sdk/credential-provider-web-identity': 3.972.34 + '@aws-sdk/nested-clients': 3.997.2 '@aws-sdk/types': 3.973.8 '@smithy/credential-provider-imds': 4.2.14 '@smithy/property-provider': 4.2.14 @@ -6932,10 +7074,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.37': + '@aws-sdk/credential-provider-login@3.972.34': dependencies: - '@aws-sdk/core': 3.974.7 - '@aws-sdk/nested-clients': 3.997.5 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/nested-clients': 3.997.2 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 @@ -6945,14 +7087,14 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.38': + '@aws-sdk/credential-provider-node@3.972.35': dependencies: - '@aws-sdk/credential-provider-env': 3.972.33 - '@aws-sdk/credential-provider-http': 3.972.35 - '@aws-sdk/credential-provider-ini': 3.972.37 - '@aws-sdk/credential-provider-process': 3.972.33 - '@aws-sdk/credential-provider-sso': 3.972.37 - '@aws-sdk/credential-provider-web-identity': 3.972.37 + '@aws-sdk/credential-provider-env': 3.972.30 + '@aws-sdk/credential-provider-http': 3.972.32 + '@aws-sdk/credential-provider-ini': 3.972.34 + '@aws-sdk/credential-provider-process': 3.972.30 + '@aws-sdk/credential-provider-sso': 3.972.34 + '@aws-sdk/credential-provider-web-identity': 3.972.34 '@aws-sdk/types': 3.973.8 '@smithy/credential-provider-imds': 4.2.14 '@smithy/property-provider': 4.2.14 @@ -6962,20 +7104,20 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.33': + '@aws-sdk/credential-provider-process@3.972.30': dependencies: - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.37': + '@aws-sdk/credential-provider-sso@3.972.34': dependencies: - '@aws-sdk/core': 3.974.7 - '@aws-sdk/nested-clients': 3.997.5 - '@aws-sdk/token-providers': 3.1039.0 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/nested-clients': 3.997.2 + '@aws-sdk/token-providers': 3.1035.0 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -6984,10 +7126,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.37': + '@aws-sdk/credential-provider-web-identity@3.972.34': dependencies: - '@aws-sdk/core': 3.974.7 - '@aws-sdk/nested-clients': 3.997.5 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/nested-clients': 3.997.2 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -7031,32 +7173,32 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.36': + '@aws-sdk/middleware-sdk-s3@3.972.33': dependencies: - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 + '@smithy/util-stream': 4.5.24 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.37': + '@aws-sdk/middleware-user-agent@3.972.34': dependencies: - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.3 tslib: 2.8.1 '@aws-sdk/middleware-websocket@3.972.16': @@ -7074,45 +7216,45 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.5': + '@aws-sdk/nested-clients@3.997.2': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.7 + '@aws-sdk/core': 3.974.4 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.37 + '@aws-sdk/middleware-user-agent': 3.972.34 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.24 + '@aws-sdk/signature-v4-multi-region': 3.996.21 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.23 + '@aws-sdk/util-user-agent-node': 3.973.20 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/fetch-http-handler': 5.3.17 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.7 - '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-endpoint': 4.4.31 + '@smithy/middleware-retry': 4.5.4 + '@smithy/middleware-serde': 4.2.19 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 + '@smithy/node-http-handler': 4.6.0 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-defaults-mode-browser': 4.3.48 + '@smithy/util-defaults-mode-node': 4.2.53 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.3 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -7126,19 +7268,19 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.24': + '@aws-sdk/signature-v4-multi-region@3.996.21': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.36 + '@aws-sdk/middleware-sdk-s3': 3.972.33 '@aws-sdk/types': 3.973.8 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1039.0': + '@aws-sdk/token-providers@3.1035.0': dependencies: - '@aws-sdk/core': 3.974.7 - '@aws-sdk/nested-clients': 3.997.5 + '@aws-sdk/core': 3.974.4 + '@aws-sdk/nested-clients': 3.997.2 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -7182,20 +7324,19 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.23': + '@aws-sdk/util-user-agent-node@3.973.20': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.37 + '@aws-sdk/middleware-user-agent': 3.972.34 '@aws-sdk/types': 3.973.8 '@smithy/node-config-provider': 4.3.14 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.22': + '@aws-sdk/xml-builder@3.972.18': dependencies: - '@nodable/entities': 2.1.0 '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.2 + fast-xml-parser: 5.5.8 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -8124,20 +8265,20 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@larksuite/openclaw-lark@2026.4.8(openclaw@2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13))': + '@larksuite/openclaw-lark@2026.4.8(openclaw@2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13))': dependencies: - '@larksuiteoapi/node-sdk': 1.62.1 + '@larksuiteoapi/node-sdk': 1.62.0 '@sinclair/typebox': 0.34.48 image-size: 2.0.2 zod: 4.3.6 optionalDependencies: - openclaw: 2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13) + openclaw: 2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13) transitivePeerDependencies: - bufferutil - debug - utf-8-validate - '@larksuiteoapi/node-sdk@1.62.1': + '@larksuiteoapi/node-sdk@1.62.0': dependencies: axios: 1.13.6(debug@4.4.3) lodash.identity: 3.0.0 @@ -8191,48 +8332,48 @@ snapshots: transitivePeerDependencies: - supports-color - '@mariozechner/clipboard-darwin-arm64@0.3.2': + '@mariozechner/clipboard-darwin-arm64@0.3.3': optional: true - '@mariozechner/clipboard-darwin-universal@0.3.2': + '@mariozechner/clipboard-darwin-universal@0.3.3': optional: true - '@mariozechner/clipboard-darwin-x64@0.3.2': + '@mariozechner/clipboard-darwin-x64@0.3.3': optional: true - '@mariozechner/clipboard-linux-arm64-gnu@0.3.2': + '@mariozechner/clipboard-linux-arm64-gnu@0.3.3': optional: true - '@mariozechner/clipboard-linux-arm64-musl@0.3.2': + '@mariozechner/clipboard-linux-arm64-musl@0.3.3': optional: true - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.2': + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.3': optional: true - '@mariozechner/clipboard-linux-x64-gnu@0.3.2': + '@mariozechner/clipboard-linux-x64-gnu@0.3.3': optional: true - '@mariozechner/clipboard-linux-x64-musl@0.3.2': + '@mariozechner/clipboard-linux-x64-musl@0.3.3': optional: true - '@mariozechner/clipboard-win32-arm64-msvc@0.3.2': + '@mariozechner/clipboard-win32-arm64-msvc@0.3.3': optional: true - '@mariozechner/clipboard-win32-x64-msvc@0.3.2': + '@mariozechner/clipboard-win32-x64-msvc@0.3.3': optional: true - '@mariozechner/clipboard@0.3.2': + '@mariozechner/clipboard@0.3.3': optionalDependencies: - '@mariozechner/clipboard-darwin-arm64': 0.3.2 - '@mariozechner/clipboard-darwin-universal': 0.3.2 - '@mariozechner/clipboard-darwin-x64': 0.3.2 - '@mariozechner/clipboard-linux-arm64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-arm64-musl': 0.3.2 - '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-x64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-x64-musl': 0.3.2 - '@mariozechner/clipboard-win32-arm64-msvc': 0.3.2 - '@mariozechner/clipboard-win32-x64-msvc': 0.3.2 + '@mariozechner/clipboard-darwin-arm64': 0.3.3 + '@mariozechner/clipboard-darwin-universal': 0.3.3 + '@mariozechner/clipboard-darwin-x64': 0.3.3 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.3 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.3 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.3 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.3 + '@mariozechner/clipboard-linux-x64-musl': 0.3.3 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.3 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.3 optional: true '@mariozechner/jiti@2.6.5': @@ -8242,8 +8383,21 @@ snapshots: '@mariozechner/pi-agent-core@0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - typebox: 1.1.28 + '@mariozechner/pi-ai': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + typebox: 1.1.33 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-agent-core@0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + typebox: 1.1.33 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -8256,14 +8410,36 @@ snapshots: '@mariozechner/pi-ai@0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1039.0 + '@aws-sdk/client-bedrock-runtime': 3.1035.0 '@google/genai': 1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 chalk: 5.6.2 openai: 6.26.0(ws@8.20.0)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 - typebox: 1.1.28 + typebox: 1.1.33 + undici: 7.24.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-ai@0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.1035.0 + '@google/genai': 1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + '@mistralai/mistralai': 2.2.1 + chalk: 5.6.2 + openai: 6.26.0(ws@8.20.0)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + typebox: 1.1.33 undici: 7.24.6 zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: @@ -8278,9 +8454,9 @@ snapshots: '@mariozechner/pi-coding-agent@0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.70.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.70.0 + '@mariozechner/pi-agent-core': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.70.2 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -8294,12 +8470,12 @@ snapshots: minimatch: 10.2.4 proper-lockfile: 4.1.2 strip-ansi: 7.2.0 - typebox: 1.1.28 + typebox: 1.1.33 undici: 7.24.6 uuid: 14.0.0 yaml: 2.8.3 optionalDependencies: - '@mariozechner/clipboard': 0.3.2 + '@mariozechner/clipboard': 0.3.3 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -8319,6 +8495,16 @@ snapshots: optionalDependencies: koffi: 2.15.2 + '@mariozechner/pi-tui@0.70.2': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + marked: 15.0.12 + mime-types: 3.0.2 + optionalDependencies: + koffi: 2.15.2 + '@mistralai/mistralai@2.2.1': dependencies: ws: 8.20.0 @@ -8350,71 +8536,129 @@ snapshots: transitivePeerDependencies: - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@mozilla/readability@0.6.0': {} + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + '@napi-rs/canvas-android-arm64@0.1.80': optional: true '@napi-rs/canvas-android-arm64@0.1.97': optional: true + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.80': optional: true '@napi-rs/canvas-darwin-arm64@0.1.97': optional: true + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.80': optional: true '@napi-rs/canvas-darwin-x64@0.1.97': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': optional: true '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-arm64-gnu@0.1.97': optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.80': optional: true '@napi-rs/canvas-linux-arm64-musl@0.1.97': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.80': optional: true '@napi-rs/canvas-linux-x64-gnu@0.1.97': optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.80': optional: true '@napi-rs/canvas-linux-x64-musl@0.1.97': optional: true + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.80': optional: true '@napi-rs/canvas-win32-x64-msvc@0.1.97': optional: true + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + '@napi-rs/canvas@0.1.80': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.80 @@ -8441,6 +8685,7 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.97 '@napi-rs/canvas-win32-arm64-msvc': 0.1.97 '@napi-rs/canvas-win32-x64-msvc': 0.1.97 + optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)': dependencies: @@ -8452,8 +8697,6 @@ snapshots: '@noble/hashes@2.0.1': optional: true - '@nodable/entities@2.1.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9055,7 +9298,7 @@ snapshots: '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/core@3.23.17': + '@smithy/core@3.23.16': dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 @@ -9063,7 +9306,7 @@ snapshots: '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 + '@smithy/util-stream': 4.5.24 '@smithy/util-utf8': 4.2.2 '@smithy/uuid': 1.1.2 tslib: 2.8.1 @@ -9140,10 +9383,10 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.32': + '@smithy/middleware-endpoint@4.4.31': dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-serde': 4.2.20 + '@smithy/core': 3.23.16 + '@smithy/middleware-serde': 4.2.19 '@smithy/node-config-provider': 4.3.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 @@ -9151,22 +9394,22 @@ snapshots: '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/middleware-retry@4.5.7': + '@smithy/middleware-retry@4.5.4': dependencies: - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 - '@smithy/service-error-classification': 4.3.1 - '@smithy/smithy-client': 4.12.13 + '@smithy/service-error-classification': 4.3.0 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.3 '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.20': + '@smithy/middleware-serde@4.2.19': dependencies: - '@smithy/core': 3.23.17 + '@smithy/core': 3.23.16 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -9183,7 +9426,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.6.1': + '@smithy/node-http-handler@4.6.0': dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/querystring-builder': 4.2.14 @@ -9211,7 +9454,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/service-error-classification@4.3.1': + '@smithy/service-error-classification@4.3.0': dependencies: '@smithy/types': 4.14.1 @@ -9231,14 +9474,14 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/smithy-client@4.12.13': + '@smithy/smithy-client@4.12.12': dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-endpoint': 4.4.32 + '@smithy/core': 3.23.16 + '@smithy/middleware-endpoint': 4.4.31 '@smithy/middleware-stack': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 + '@smithy/util-stream': 4.5.24 tslib: 2.8.1 '@smithy/types@4.14.1': @@ -9279,20 +9522,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.49': + '@smithy/util-defaults-mode-browser@4.3.48': dependencies: '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.54': + '@smithy/util-defaults-mode-node@4.2.53': dependencies: '@smithy/config-resolver': 4.4.17 '@smithy/credential-provider-imds': 4.2.14 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 + '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -9311,16 +9554,16 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-retry@4.3.6': + '@smithy/util-retry@4.3.3': dependencies: - '@smithy/service-error-classification': 4.3.1 + '@smithy/service-error-classification': 4.3.0 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.25': + '@smithy/util-stream@4.5.24': dependencies: '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 + '@smithy/node-http-handler': 4.6.0 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.2 @@ -9413,7 +9656,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - '@soimy/dingtalk@3.5.3(openclaw@2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13))': + '@soimy/dingtalk@3.5.3(openclaw@2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13))': dependencies: axios: 1.13.6(debug@4.4.3) dingtalk-stream: 2.1.5 @@ -9422,7 +9665,7 @@ snapshots: pdf-parse: 2.4.5 zod: 4.3.6 optionalDependencies: - openclaw: 2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13) + openclaw: 2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13) transitivePeerDependencies: - bufferutil - debug @@ -9439,7 +9682,7 @@ snapshots: dependencies: qrcode-terminal: 0.12.0 - '@tencent-weixin/openclaw-weixin@2.3.1': + '@tencent-weixin/openclaw-weixin@2.1.9': dependencies: qrcode-terminal: 0.12.0 zod: 4.3.6 @@ -9536,6 +9779,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@8.0.0': + dependencies: + diff: 9.0.0 + '@types/esrecurse@4.3.1': {} '@types/estree-jsx@1.0.5': @@ -9609,6 +9856,9 @@ snapshots: '@types/retry@0.12.0': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -9790,10 +10040,10 @@ snapshots: - debug - utf-8-validate - '@wecom/wecom-openclaw-plugin@2026.4.29': + '@wecom/wecom-openclaw-plugin@2026.4.2201': dependencies: '@wecom/aibot-node-sdk': 1.0.6 - fast-xml-parser: 5.7.2 + fast-xml-parser: 5.5.10 file-type: 21.3.4 undici: 7.24.6 zod: 4.3.6 @@ -10045,7 +10295,7 @@ snapshots: dependencies: bare-events: 2.8.2 bare-path: 3.0.0 - bare-stream: 2.13.1(bare-events@2.8.2) + bare-stream: 2.13.0(bare-events@2.8.2) bare-url: 2.4.2 fast-fifo: 1.3.2 transitivePeerDependencies: @@ -10058,7 +10308,7 @@ snapshots: dependencies: bare-os: 3.9.0 - bare-stream@2.13.1(bare-events@2.8.2): + bare-stream@2.13.0(bare-events@2.8.2): dependencies: streamx: 2.25.0 teex: 1.0.1 @@ -10555,6 +10805,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + dijkstrajs@1.0.3: {} dingbat-to-unicode@1.0.1: {} @@ -10621,6 +10873,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -11026,21 +11282,30 @@ snapshots: dependencies: fast-string-width: 1.1.0 - fast-xml-builder@1.1.5: + fast-xml-builder@1.1.4: dependencies: - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.2.1 - fast-xml-parser@5.7.2: + fast-xml-parser@5.5.10: dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 - path-expression-matcher: 1.5.0 - strnum: 2.2.3 + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.1 + strnum: 2.2.2 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.1 + strnum: 2.2.2 fastq@1.20.1: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -11131,6 +11396,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + format@0.2.2: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -11991,6 +12258,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@15.0.12: {} matcher@3.0.0: @@ -12024,6 +12293,17 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-gfm-autolink-literal@2.0.1: dependencies: '@types/mdast': 4.0.4 @@ -12194,6 +12474,13 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -12475,6 +12762,11 @@ snapshots: mkdirp@1.0.4: {} + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + motion-dom@12.38.0: dependencies: motion-utils: 12.36.0 @@ -12558,9 +12850,6 @@ snapshots: node-machine-id@1.1.12: {} - node-readable-to-web-readable-stream@0.4.2: - optional: true - node-releases@2.0.36: {} nopt@8.1.0: @@ -12617,7 +12906,7 @@ snapshots: ws: 8.20.0 zod: 4.3.6 - openclaw@2026.4.23(@napi-rs/canvas@0.1.97)(encoding@0.1.13): + openclaw@2026.4.23(@napi-rs/canvas@0.1.100)(encoding@0.1.13): dependencies: '@agentclientprotocol/sdk': 0.19.0(zod@4.3.6) '@anthropic-ai/vertex-sdk': 0.16.0(encoding@0.1.13)(zod@4.3.6) @@ -12630,7 +12919,7 @@ snapshots: '@mariozechner/pi-tui': 0.70.0 '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) '@mozilla/readability': 0.6.0 - '@napi-rs/canvas': 0.1.97 + '@napi-rs/canvas': 0.1.100 '@vincentkoc/qrcode-tui': 0.2.1 ajv: 8.18.0 chalk: 5.6.2 @@ -12650,7 +12939,7 @@ snapshots: markdown-it: 14.1.1 openai: 6.34.0(ws@8.20.0)(zod@4.3.6) osc-progress: 0.3.0 - pdfjs-dist: 5.6.205 + pdfjs-dist: 5.7.284 proxy-agent: 8.0.1 semver: 7.7.4 sharp: 0.34.5 @@ -12832,7 +13121,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} + path-expression-matcher@1.2.1: {} path-is-absolute@1.0.1: {} @@ -12863,10 +13152,9 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.97 - pdfjs-dist@5.6.205: + pdfjs-dist@5.7.284: optionalDependencies: - '@napi-rs/canvas': 0.1.97 - node-readable-to-web-readable-stream: 0.4.2 + '@napi-rs/canvas': 0.1.100 pe-library@0.4.1: {} @@ -13254,6 +13542,15 @@ snapshots: unist-util-visit-parents: 6.0.2 vfile: 6.0.3 + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -13550,7 +13847,7 @@ snapshots: skillflag@0.1.4: dependencies: '@clack/prompts': 1.2.0 - tar-stream: 3.2.0 + tar-stream: 3.1.8 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -13644,6 +13941,8 @@ snapshots: stat-mode@1.0.0: {} + state-local@1.0.7: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -13703,7 +14002,7 @@ snapshots: dependencies: min-indent: 1.0.1 - strnum@2.2.3: {} + strnum@2.2.2: {} strtok3@10.3.5: dependencies: @@ -13783,7 +14082,7 @@ snapshots: - tsx - yaml - tar-stream@3.2.0: + tar-stream@3.1.8: dependencies: b4a: 1.8.0 bare-fs: 4.7.1 @@ -13946,6 +14245,8 @@ snapshots: typebox@1.1.28: {} + typebox@1.1.33: {} + typescript@5.9.3: {} uc.micro@2.1.0: {} diff --git a/src/components/file-preview/ArtifactPanel.tsx b/src/components/file-preview/ArtifactPanel.tsx new file mode 100644 index 0000000..6b780ba --- /dev/null +++ b/src/components/file-preview/ArtifactPanel.tsx @@ -0,0 +1,228 @@ +/** + * Right-side artifact panel — the WorkBuddy-style split-pane sidebar + * shown next to the Chat conversation. Hosts three top-level tabs: + * + * - 变更 (changes): side-by-side diff for the focused file only (no + * in-panel file list — open a file from the run’s file cards below + * the graph, or “查看文件变更” picks the latest change). + * - 预览 (preview): rendered preview of whichever file is currently + * focused (Markdown → rendered, code → syntax-highlighted). Shares + * `focusedFile` with the changes tab so switching tabs keeps + * context. + * - 工作空间 (browser): read-only workspace tree + file preview, + * scoped to the current agent's `agent.workspace`. + * + * Open/close + tab + focused-file state lives in the + * `useArtifactPanel` zustand store so any part of the page (file cards, + * toolbar buttons, "查看文件变更 →" links) can drive it. + */ +import { useLayoutEffect, useMemo } from 'react'; +import { Eye, FileEdit, FolderTree, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { GeneratedFile } from '@/lib/generated-files'; +import type { AgentSummary } from '@/types/agent'; +import { useArtifactPanel } from '@/stores/artifact-panel'; +import type { FilePreviewTarget } from './types'; +import { FilePreviewBody } from './FilePreviewBody'; +import { WorkspaceBrowserBody } from './WorkspaceBrowserBody'; +import { WORKSPACE_BROWSER_ENABLED } from './workspace-browser-config'; + +export interface ArtifactPanelProps { + /** All files generated by the AI across the conversation. */ + files: GeneratedFile[]; + /** Currently selected agent (drives the workspace tab). */ + agent: AgentSummary | null; + /** Used to mark "本轮新增" badges on the workspace tree. */ + runStartedAt?: number | null; + /** Bumping this number triggers a workspace tree reload. */ + refreshSignal?: number; +} + +export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: ArtifactPanelProps) { + const { t } = useTranslation('chat'); + const tab = useArtifactPanel((s) => s.tab); + const visibleTab = !WORKSPACE_BROWSER_ENABLED && tab === 'browser' ? 'changes' : tab; + const setTab = useArtifactPanel((s) => s.setTab); + const focusedFile = useArtifactPanel((s) => s.focusedFile); + const setFocusedFile = useArtifactPanel((s) => s.setFocusedFile); + const close = useArtifactPanel((s) => s.close); + + return ( +
+
+
+ } + label={t('artifactPanel.tabs.changes', '变更')} + active={visibleTab === 'changes'} + onClick={() => setTab('changes')} + /> + } + label={t('artifactPanel.tabs.preview', '预览')} + active={visibleTab === 'preview'} + onClick={() => setTab('preview')} + /> + {WORKSPACE_BROWSER_ENABLED && ( + } + label={t('artifactPanel.tabs.browser', '工作空间')} + active={visibleTab === 'browser'} + onClick={() => setTab('browser')} + /> + )} +
+ +
+ +
+ {visibleTab === 'changes' && ( + setFocusedFile(f)} + /> + )} + {visibleTab === 'preview' && } + {WORKSPACE_BROWSER_ENABLED && visibleTab === 'browser' && ( + + )} +
+
+ ); +} + +interface PanelTabButtonProps { + icon: React.ReactNode; + label: string; + active: boolean; + onClick: () => void; +} + +function PanelTabButton({ icon, label, active, onClick }: PanelTabButtonProps) { + return ( + + ); +} + +function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget { + return { + filePath: file.filePath, + fileName: file.fileName, + ext: file.ext, + mimeType: file.mimeType, + contentType: file.contentType, + action: file.action, + fullContent: file.fullContent, + baseline: file.baseline, + edits: file.edits, + }; +} + +interface ChangesTabProps { + files: GeneratedFile[]; + focusedFile: FilePreviewTarget | null; + onFocus: (file: FilePreviewTarget) => void; +} + +/** + * Full-width diff for the focused file. Which file is focused comes from + * the run’s file cards or “查看文件变更 →” (auto-first); switching workspace + * tabs does not bring back a sidebar list. + */ +function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) { + const { t } = useTranslation('chat'); + + // De-dup files by path, keep the latest entry (highest lastSeenIndex). + const uniqueFiles = useMemo(() => { + const map = new Map(); + for (const f of files) { + const existing = map.get(f.filePath); + if (!existing || f.lastSeenIndex >= existing.lastSeenIndex) map.set(f.filePath, f); + } + return Array.from(map.values()).sort((a, b) => b.lastSeenIndex - a.lastSeenIndex); + }, [files]); + + // Auto-select the first file when entering this tab without one in + // focus (or when the focused file disappears, e.g. session reset). + useLayoutEffect(() => { + if (uniqueFiles.length === 0) return; + const stillExists = + focusedFile && uniqueFiles.some((f) => f.filePath === focusedFile.filePath); + if (!stillExists) { + onFocus(generatedFileToTarget(uniqueFiles[0])); + } + }, [focusedFile, uniqueFiles, onFocus]); + + if (uniqueFiles.length === 0) { + return ( +
+ {t('artifactPanel.changes.empty', '本会话尚无文件变更')} +
+ ); + } + + return focusedFile ? ( +
+ +
+ ) : ( +
+ {t('artifactPanel.changes.selectFileHint', '请点击对话中的文件卡片,或「查看文件变更」')} +
+ ); +} + +interface PreviewTabProps { + focusedFile: FilePreviewTarget | null; +} + +function PreviewTab({ focusedFile }: PreviewTabProps) { + const { t } = useTranslation('chat'); + if (!focusedFile) { + return ( +
+

+ {t('artifactPanel.preview.emptyTitle', '尚未选择文件')} +

+

+ {t( + 'artifactPanel.preview.emptyHint', + '请先点击对话里的文件卡片打开侧栏并选中文件。', + )} +

+
+ ); + } + return ; +} + +export default ArtifactPanel; diff --git a/src/components/file-preview/FilePreviewBody.tsx b/src/components/file-preview/FilePreviewBody.tsx new file mode 100644 index 0000000..1e0a937 --- /dev/null +++ b/src/components/file-preview/FilePreviewBody.tsx @@ -0,0 +1,547 @@ +/** + * Inline file preview body. + * + * Renders the icon header (file name / path / save / revert) and a + * minimal tabbed view for a single file. Documents (Markdown) render + * only their `preview` tab; code / other text files render `source`. + * A `diff` tab is added when the file is editable AND there are + * AI-applied edits to display. The metadata `info` tab has been + * intentionally removed — path / size are visible in the header bar. + * + * The `mode` prop narrows the tab set for callers that want a single, + * fixed view (e.g. the artifact panel's 预览 tab forces `preview`, and + * the 变更 tab's right pane forces `diff`). + * + * Used by: + * - `FilePreviewOverlay` for the Skills detail Sheet (read-only). + * - `ArtifactPanel`'s ChangesTab and PreviewTab. + * + * All sandbox / read-only / large-file / binary edge cases are handled + * here so callers only pass a `FilePreviewTarget` and a `readOnly` flag. + */ +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { FolderOpen, Save, ShieldAlert, Undo2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { cn } from '@/lib/utils'; +import { invokeIpc, readTextFile, writeTextFile } from '@/lib/api-client'; +import type { FilePreviewTarget } from './types'; +import { supportsInlineDiff, supportsInlineDocumentPreview } from '@/lib/generated-files'; +import { FilePreviewIcon } from './file-card-utils'; +import { formatFileSize } from './format'; +import MarkdownPreview from './MarkdownPreview'; +import ImageViewer from './ImageViewer'; + +const MonacoViewerLazy = lazy(() => import('./MonacoViewer')); +const MonacoDiffViewerLazy = lazy(() => import('./MonacoDiffViewer')); + +/** + * Tab set for the body. + * + * - 'full' – default: preview / source / diff as appropriate. + * - 'preview' – render-only; hides the diff tab and forces read-only. + * - 'diff' – diff-only; the body collapses to a single diff view + * with no tab strip, save/revert, or source toggle. + */ +export type FilePreviewBodyMode = 'full' | 'preview' | 'diff'; + +export interface FilePreviewBodyProps { + file: FilePreviewTarget; + readOnly?: boolean; + /** Compact mode reduces padding/font for use inside the side panel. */ + compact?: boolean; + /** Optional slot rendered to the LEFT of the header info (e.g. back button). */ + leadingHeader?: React.ReactNode; + /** Optional slot rendered to the RIGHT of the header (extra actions). */ + trailingHeader?: React.ReactNode; + /** Limit the visible tabs. Default: 'full'. */ + mode?: FilePreviewBodyMode; + /** When true, hide the file header (name / path / actions). */ + hideHeader?: boolean; +} + +type LoadState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; content: string; size?: number; readOnly: boolean } + | { status: 'tooLarge'; size?: number } + | { status: 'binary' } + | { status: 'outsideSandbox' } + | { status: 'error'; message: string }; + +type Tab = 'source' | 'preview' | 'diff'; + +function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[] { + // Diff-only mode short-circuits. Callers (e.g. the 变更 tab's right + // pane) want a pure git-style diff with no tab strip — but only for + // formats where inline diff is actually supported. + if (mode === 'diff') return supportsInlineDiff(file) ? ['diff'] : []; + + const tabs: Tab[] = []; + if (file.contentType === 'document') { + if (!supportsInlineDocumentPreview(file.ext)) { + return []; + } + // Markdown / plain-text style documents: rendered preview only. + tabs.push('preview'); + } else if (file.contentType === 'snapshot') { + tabs.push('preview'); + } else if (file.contentType === 'video' || file.contentType === 'audio') { + tabs.push('preview'); + } else if (file.contentType === 'code') { + tabs.push('source'); + } else { + tabs.push('source'); + } + // Diff tab appears in 'full' mode whenever we captured a Write/Edit + // payload — read-only is fine, the diff is informational only. + if ( + mode === 'full' && + supportsInlineDiff(file) && + (file.fullContent != null || (file.edits != null && file.edits.length > 0)) + ) { + tabs.push('diff'); + } + return tabs; +} + +function pickInitialTab(tabs: Tab[], file: FilePreviewTarget): Tab { + if (file.contentType === 'document' && tabs.includes('preview')) return 'preview'; + // For changes view (edited code), prefer the diff tab if present so + // the user sees the change immediately on click. + if (tabs.includes('diff') && file.contentType !== 'document') return 'diff'; + return tabs[0] ?? 'source'; +} + +function normaliseEol(s: string): string { + return s.replace(/\r\n/g, '\n'); +} + +type DiffPair = { + /** Left pane. `null` makes Monaco render the side as empty (new-file). */ + oldContent: string | null; + /** Right pane. */ + newContent: string; + /** + * - `whole` – right pane is the full file (Write tool). + * - `snippet` – left/right are the joined Edit op old/new strings. + * - `unavailable` – the chat captured no payload to diff against. + */ + kind: 'whole' | 'snippet' | 'unavailable'; +}; + +/** Visual separator between MultiEdit hunks. */ +const SNIPPET_SEPARATOR = '\n\n'; + +/** + * Build a diff pair purely from the captured tool payload — never reads + * disk. Edit tools show the exact old → new snippet swap. Write-family + * tools prefer the captured baseline when available, so modified files + * render a true before/after diff instead of a misleading all-green view. + */ +function computeDiffPair(file: FilePreviewTarget): DiffPair { + // Edit / StrReplace / MultiEdit — show the snippet swap directly. + if (file.edits && file.edits.length > 0) { + const lefts = file.edits.map((op) => normaliseEol(op.old ?? '')); + const rights = file.edits.map((op) => normaliseEol(op.new ?? '')); + const left = lefts.join(SNIPPET_SEPARATOR); + const right = rights.join(SNIPPET_SEPARATOR); + if (left || right) { + return { oldContent: left, newContent: right, kind: 'snippet' }; + } + } + + if (file.fullContent != null) { + if (file.baseline?.status === 'ok') { + return { + oldContent: normaliseEol(file.baseline.content), + newContent: normaliseEol(file.fullContent), + kind: 'whole', + }; + } + if (file.baseline?.status === 'missing') { + return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' }; + } + if (file.baseline?.status === 'unavailable') { + return { oldContent: null, newContent: '', kind: 'unavailable' }; + } + if (file.action === 'created') { + return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' }; + } + return { oldContent: null, newContent: '', kind: 'unavailable' }; + } + + return { oldContent: null, newContent: '', kind: 'unavailable' }; +} + +export function FilePreviewBody({ + file, + readOnly = false, + compact = false, + leadingHeader, + trailingHeader, + mode = 'full', + hideHeader = false, +}: FilePreviewBodyProps) { + const { t } = useTranslation('chat'); + const [state, setState] = useState({ status: 'idle' }); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [tab, setTab] = useState('source'); + const [size, setSize] = useState(undefined); + + // Preview / diff modes are read-only by definition — those views are + // for inspecting content, not editing it. + const enforcedReadOnly = readOnly || mode === 'preview' || mode === 'diff'; + const tabs = useMemo(() => tabsForFile(file, mode), [file, mode]); + const unsupportedPreviewFormat = file.contentType === 'document' && !supportsInlineDocumentPreview(file.ext); + const unsupportedDiffFormat = mode === 'diff' && !supportsInlineDiff(file); + + useEffect(() => { + setTab(pickInitialTab(tabs, file)); + + // Diff-only mode renders entirely from the captured tool payload — + // no disk read needed, so we can mark the body "ready" immediately. + if (mode === 'diff') { + setState({ status: 'ready', content: '', readOnly: enforcedReadOnly }); + setDraft(null); + return; + } + + if (unsupportedPreviewFormat) { + setState({ status: 'ready', content: '', readOnly: enforcedReadOnly }); + setDraft(null); + return; + } + + if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') { + setState({ status: 'ready', content: '', readOnly: enforcedReadOnly }); + setDraft(null); + return; + } + + let cancelled = false; + setState({ status: 'loading' }); + readTextFile(file.filePath) + .then((res) => { + if (cancelled) return; + if (!res.ok) { + if (res.error === 'tooLarge') { + setState({ status: 'tooLarge', size: res.size }); + return; + } + if (res.error === 'binary') { + setState({ status: 'binary' }); + return; + } + if (res.error === 'outsideSandbox') { + setState({ status: 'outsideSandbox' }); + return; + } + setState({ status: 'error', message: String(res.error ?? 'unknown') }); + return; + } + setState({ + status: 'ready', + content: res.content ?? '', + size: res.size, + readOnly: enforcedReadOnly || !!res.readOnly, + }); + setDraft(res.content ?? ''); + setSize(res.size); + }) + .catch((err) => { + if (cancelled) return; + setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + }); + return () => { + cancelled = true; + }; + }, [file, enforcedReadOnly, mode, tabs, unsupportedPreviewFormat]); + + const effectiveReadOnly = state.status === 'ready' ? state.readOnly : true; + const dirty = + state.status === 'ready' && !state.readOnly && draft != null && draft !== state.content; + + const handleSave = useCallback(async () => { + if (!dirty || draft == null) return; + setSaving(true); + try { + const res = await writeTextFile(file.filePath, draft); + if (!res.ok) throw new Error(res.error ?? 'unknown'); + setState({ status: 'ready', content: draft, size, readOnly: false }); + toast.success(t('filePreview.toast.saved', '已保存到磁盘')); + } catch (err) { + const code = err instanceof Error ? err.message : String(err); + const localized = + code === 'outsideSandbox' + ? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝写入') + : code === 'readOnlyRoot' + ? t('filePreview.errors.readOnlyRoot', '该文件位于只读位置(如内置技能),无法修改') + : t('filePreview.toast.saveFailed', { defaultValue: '保存失败:{{error}}', error: code }); + toast.error(localized); + } finally { + setSaving(false); + } + }, [file, dirty, draft, size, t]); + + const handleRevert = useCallback(() => { + if (state.status !== 'ready') return; + setDraft(state.content); + }, [state]); + + const handleOpenInFinder = useCallback(() => { + invokeIpc('shell:showItemInFolder', file.filePath).catch(() => { + toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示')); + }); + }, [file, t]); + + const renderUnsupportedFormat = () => ( +
+
+

+ {t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')} +

+

+ {t( + 'filePreview.errors.unsupportedFormatHint', + '当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。', + )} +

+
+ +
+ ); + + const renderBody = () => { + if (unsupportedPreviewFormat || unsupportedDiffFormat) { + return renderUnsupportedFormat(); + } + if (state.status === 'loading' || state.status === 'idle') { + return ( +
+ +
+ ); + } + if (state.status === 'tooLarge') { + return ( +
+

+ {t('filePreview.errors.tooLarge', { + defaultValue: '文件过大({{size}}),已禁用预览', + size: formatFileSize(state.size ?? 0) || '> 2MB', + })} +

+ +
+ ); + } + if (state.status === 'binary') { + return ( +
+

{t('filePreview.errors.binary', '二进制文件不支持文本预览')}

+ +
+ ); + } + if (state.status === 'outsideSandbox') { + return ( +
+
+ +
+
+

+ {t('filePreview.errors.outsideSandboxTitle', '此文件位于沙盒外')} +

+

+ {t( + 'filePreview.errors.outsideSandboxHint', + '出于安全考虑,ClawX 仅允许预览 ~/.openclaw、应用资源以及内置技能目录中的文件。可在 Finder 中查看。', + )} +

+
+ +
+ ); + } + if (state.status === 'error') { + const errMsg = state.message; + const hint = + errMsg === 'notFound' + ? t('filePreview.errors.notFound', '文件不存在') + : t('filePreview.errors.loadFailed', { defaultValue: '加载失败:{{error}}', error: errMsg }); + return ( +
+

{hint}

+ +
+ ); + } + + return ( + setTab(next as Tab)} className="flex h-full flex-col"> + {/* Hide the tab strip when there's only one tab — keeps the UI + quiet for the common case (just preview / just source). */} + {tabs.length > 1 && ( + + {tabs.map((id) => ( + + {id === 'source' && t('filePreview.tabs.source', '源码')} + {id === 'preview' && t('filePreview.tabs.preview', '预览')} + {id === 'diff' && t('filePreview.tabs.changes', '变更')} + + ))} + + )} +
1 && 'border-t border-black/5 dark:border-white/10', + )} + > + {tabs.includes('source') && ( + + {file.contentType === 'snapshot' ? ( + + ) : ( + + +
+ } + > + setDraft(next)} + /> + + )} + + )} + {tabs.includes('preview') && ( + + {file.contentType === 'snapshot' ? ( + + ) : file.contentType === 'document' ? ( + + ) : ( +
+ {t('filePreview.errors.noPreview', '该文件没有预览')} +
+ )} +
+ )} + {tabs.includes('diff') && ( + + + + + } + > + {(() => { + const pair = computeDiffPair(file); + if (pair.kind === 'unavailable') { + return ( +
+

+ {t( + 'filePreview.diff.unavailable', + '本会话没有抓到这个文件的精确变更基线,无法生成 diff。', + )} +

+

+ {t( + 'filePreview.diff.unavailableHint', + '可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。', + )} +

+
+ ); + } + return ( + + ); + })()} +
+
+ )} + +
+ ); + }; + + return ( +
+ {!hideHeader && ( +
+
+ {leadingHeader} + +
+

{file.fileName}

+

{file.filePath}

+
+
+
+ {!effectiveReadOnly && state.status === 'ready' && ( + <> + + + + )} + {trailingHeader} +
+
+ )} +
{renderBody()}
+
+ ); +} + +export default FilePreviewBody; diff --git a/src/components/file-preview/FilePreviewOverlay.tsx b/src/components/file-preview/FilePreviewOverlay.tsx new file mode 100644 index 0000000..bc3d09b --- /dev/null +++ b/src/components/file-preview/FilePreviewOverlay.tsx @@ -0,0 +1,32 @@ +/** + * Sheet-based wrapper around `FilePreviewBody`, used by the Skills page + * (read-only) to preview SKILL.md and friends in a full-screen overlay. + * + * The Chat page uses the inline `ArtifactPanel` instead of this component. + */ +import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { FilePreviewBody } from './FilePreviewBody'; +import type { FilePreviewTarget } from './types'; + +export type { FilePreviewTarget } from './types'; + +export interface FilePreviewOverlayProps { + file: FilePreviewTarget | null; + readOnly?: boolean; + onClose: () => void; +} + +export function FilePreviewOverlay({ file, readOnly = false, onClose }: FilePreviewOverlayProps) { + return ( + { if (!open) onClose(); }}> + + {file && } + + + ); +} + +export default FilePreviewOverlay; diff --git a/src/components/file-preview/GeneratedFilesPanel.tsx b/src/components/file-preview/GeneratedFilesPanel.tsx new file mode 100644 index 0000000..4cec647 --- /dev/null +++ b/src/components/file-preview/GeneratedFilesPanel.tsx @@ -0,0 +1,75 @@ +/** + * Inline panel showing files the AI wrote/edited in the current run. + * Lives directly under the ExecutionGraphCard for each user trigger + * (see Chat/index.tsx). + */ +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { computeLineStats, supportsInlineDiff, type GeneratedFile } from '@/lib/generated-files'; + +export interface GeneratedFilesPanelProps { + files: GeneratedFile[]; + onOpen: (file: GeneratedFile) => void; + className?: string; +} + +export function GeneratedFilesPanel({ files, onOpen, className }: GeneratedFilesPanelProps) { + const { t } = useTranslation('chat'); + + if (!files.length) return null; + + return ( +
+
+

+ {t('generatedFiles.title', { count: files.length, defaultValue: '文件变更({{count}} 个)' })} +

+
+
+ {files.map((file) => { + const lineStats = computeLineStats(file); + const clickable = supportsInlineDiff(file); + return ( + + ); + })} +
+
+ ); +} + +export default GeneratedFilesPanel; diff --git a/src/components/file-preview/ImageViewer.tsx b/src/components/file-preview/ImageViewer.tsx new file mode 100644 index 0000000..7edc6a0 --- /dev/null +++ b/src/components/file-preview/ImageViewer.tsx @@ -0,0 +1,58 @@ +/** + * Read-only image viewer with fit-to-window + click-to-zoom toggle. + * + * Renders the image directly off the disk via `file://` so we don't need + * to base64-encode it through IPC. clawx's renderer already loads via + * file:// in production so the protocol is allowlisted. + */ +import { useState } from 'react'; +import { ZoomIn, ZoomOut } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export interface ImageViewerProps { + filePath: string; + fileName: string; + className?: string; +} + +function toFileUrl(path: string): string { + const norm = path.replace(/\\/g, '/'); + if (norm.startsWith('file://')) return norm; + if (norm.startsWith('/')) return `file://${norm}`; + return `file:///${norm}`; +} + +export default function ImageViewer({ filePath, fileName, className }: ImageViewerProps) { + const [zoomed, setZoomed] = useState(false); + + return ( +
+
+ +
+
+ {fileName} setZoomed((v) => !v)} + draggable={false} + /> +
+
+ ); +} diff --git a/src/components/file-preview/MarkdownPreview.tsx b/src/components/file-preview/MarkdownPreview.tsx new file mode 100644 index 0000000..41a9906 --- /dev/null +++ b/src/components/file-preview/MarkdownPreview.tsx @@ -0,0 +1,121 @@ +/** + * Rendered Markdown preview. + * + * Two important deltas from a vanilla ReactMarkdown setup: + * + * 1. `remark-frontmatter` is enabled with the `yaml`/`toml` flavours so a + * file like `SKILL.md` doesn't render its `---\nname: …\n---` block as + * a giant paragraph between two horizontal rules. The frontmatter is + * extracted into a side-rendered metadata card above the body so users + * can still see it without the noise. + * + * 2. We render heading / inline-code / fenced-code with explicit Tailwind + * classes instead of relying solely on `.prose`, because the project + * doesn't ship `@tailwindcss/typography` — only the small subset of + * `.prose` rules from `globals.css` exists. + */ +import { useMemo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import remarkFrontmatter from 'remark-frontmatter'; +import rehypeKatex from 'rehype-katex'; +import { cn } from '@/lib/utils'; + +export interface MarkdownPreviewProps { + source: string; + className?: string; +} + +interface FrontmatterSplit { + body: string; + yaml: string | null; +} + +function splitFrontmatter(source: string): FrontmatterSplit { + if (!source.startsWith('---')) { + return { body: source, yaml: null }; + } + // Match opening fence + YAML body + closing fence on its own line. + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) return { body: source, yaml: null }; + const yaml = match[1].trim(); + const body = source.slice(match[0].length); + return { body, yaml: yaml.length > 0 ? yaml : null }; +} + +export default function MarkdownPreview({ source, className }: MarkdownPreviewProps) { + const { body, yaml } = useMemo(() => splitFrontmatter(source), [source]); + + return ( +
+ {yaml && ( +
+          {yaml}
+        
+ )} + ( +

+ {children} +

+ ), + h2: ({ children, ...props }) => ( +

+ {children} +

+ ), + h3: ({ children, ...props }) => ( +

+ {children} +

+ ), + h4: ({ children, ...props }) => ( +

+ {children} +

+ ), + a: ({ children, href }) => ( + + {children} + + ), + code: ({ className: codeClass, children, ...props }) => { + const match = /language-(\w+)/.exec(codeClass || ''); + const isInline = !match && !codeClass; + if (isInline) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
+              {children}
+            
+ ), + }} + > + {body} +
+
+ ); +} diff --git a/src/components/file-preview/MonacoDiffViewer.tsx b/src/components/file-preview/MonacoDiffViewer.tsx new file mode 100644 index 0000000..5436fbe --- /dev/null +++ b/src/components/file-preview/MonacoDiffViewer.tsx @@ -0,0 +1,86 @@ +/** + * Monaco split diff editor (VS Code–style). Replaces the custom table-based + * `SplitDiffViewer`: same Monaco bundle as `MonacoViewer`, with syntax + * highlighting, scroll sync, and virtualisation for large files. + */ +import { useMemo } from 'react'; +import { DiffEditor, languageForPath } from '@/lib/monaco/loader'; +import { useSettingsStore } from '@/stores/settings'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +export interface MonacoDiffViewerProps { + filePath: string; + /** Left pane; `null`/`undefined` treated as empty (e.g. new file → all additions). */ + original: string | null | undefined; + modified: string; + className?: string; +} + +function resolveMonacoTheme(theme: string | undefined): string { + if (theme === 'dark') return 'vs-dark'; + if (theme === 'light') return 'vs'; + const prefersDark = + typeof window !== 'undefined' + && window.matchMedia + && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'vs-dark' : 'vs'; +} + +function diffModelPath(filePath: string, side: 'orig' | 'mod'): string { + const key = encodeURIComponent(filePath.replace(/\\/g, '/')); + return `inmemory://clawx-diff/${side}/${key}`; +} + +export default function MonacoDiffViewer({ + filePath, + original, + modified, + className, +}: MonacoDiffViewerProps) { + const theme = useSettingsStore((s) => s.theme); + const language = useMemo(() => languageForPath(filePath), [filePath]); + const monacoTheme = resolveMonacoTheme(theme); + const originalPath = useMemo(() => diffModelPath(filePath, 'orig'), [filePath]); + const modifiedPath = useMemo(() => diffModelPath(filePath, 'mod'), [filePath]); + const left = original ?? ''; + + return ( +
+ + +
+ } + options={{ + readOnly: true, + domReadOnly: true, + originalEditable: false, + renderSideBySide: true, + useInlineViewWhenSpaceIsLimited: false, + renderIndicators: true, + renderOverviewRuler: true, + renderMarginRevertIcon: false, + minimap: { enabled: false }, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + scrollBeyondLastLine: false, + smoothScrolling: true, + wordWrap: 'on', + diffWordWrap: 'on', + automaticLayout: true, + guides: { indentation: false }, + stickyScroll: { enabled: false }, + padding: { top: 8, bottom: 8 }, + }} + /> + + ); +} diff --git a/src/components/file-preview/MonacoViewer.tsx b/src/components/file-preview/MonacoViewer.tsx new file mode 100644 index 0000000..bad05b8 --- /dev/null +++ b/src/components/file-preview/MonacoViewer.tsx @@ -0,0 +1,76 @@ +/** + * Monaco-backed text/code viewer. + * + * Lazily loads Monaco on first render (the parent overlay wraps this in + * ``), and resolves the language from the file extension so + * highlighting works for the dozens of file types the editor ships with + * out of the box. + */ +import { useMemo } from 'react'; +import { Editor, languageForPath } from '@/lib/monaco/loader'; +import { useSettingsStore } from '@/stores/settings'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +export interface MonacoViewerProps { + filePath: string; + value: string; + onChange?: (value: string) => void; + readOnly?: boolean; + className?: string; +} + +function resolveMonacoTheme(theme: string | undefined): string { + if (theme === 'dark') return 'vs-dark'; + if (theme === 'light') return 'vs'; + // 'system' — derive from media query at the moment of mount + const prefersDark = typeof window !== 'undefined' + && window.matchMedia + && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'vs-dark' : 'vs'; +} + +export default function MonacoViewer({ + filePath, + value, + onChange, + readOnly = false, + className, +}: MonacoViewerProps) { + const theme = useSettingsStore((s) => s.theme); + const language = useMemo(() => languageForPath(filePath), [filePath]); + const monacoTheme = resolveMonacoTheme(theme); + + return ( +
+ onChange?.(next ?? '')} + theme={monacoTheme} + loading={ +
+ +
+ } + options={{ + readOnly, + domReadOnly: readOnly, + minimap: { enabled: false }, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + scrollBeyondLastLine: false, + smoothScrolling: true, + wordWrap: 'on', + automaticLayout: true, + renderLineHighlight: readOnly ? 'none' : 'line', + padding: { top: 12, bottom: 12 }, + stickyScroll: { enabled: false }, + guides: { indentation: false }, + }} + /> +
+ ); +} diff --git a/src/components/file-preview/PanelResizeDivider.tsx b/src/components/file-preview/PanelResizeDivider.tsx new file mode 100644 index 0000000..41bebc8 --- /dev/null +++ b/src/components/file-preview/PanelResizeDivider.tsx @@ -0,0 +1,107 @@ +/** + * Drag-to-resize handle that sits between the chat column and the + * artifact panel on the Chat page. + * + * On `pointerdown` we capture the pointer, attach window-level + * `pointermove` / `pointerup` listeners, and convert the cursor X (in + * pixels) into a panel-width percentage relative to the supplied + * `containerRef`. The new width is clamped via the store. + */ +import { useCallback, useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import { + ARTIFACT_PANEL_MAX_WIDTH, + ARTIFACT_PANEL_MIN_WIDTH, + useArtifactPanel, +} from '@/stores/artifact-panel'; + +export interface PanelResizeDividerProps { + /** The flex container that holds chat-left + panel-right. */ + containerRef: React.RefObject; + className?: string; +} + +export function PanelResizeDivider({ containerRef, className }: PanelResizeDividerProps) { + const setWidthPct = useArtifactPanel((s) => s.setWidthPct); + // Store window listeners in refs so the up-handler can remove the + // matching move-handler without a self-referential closure (which the + // `react-hooks/immutability` rule flags). + const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null); + const upHandlerRef = useRef<((e: PointerEvent) => void) | null>(null); + + const stopDragging = useCallback(() => { + if (moveHandlerRef.current) { + window.removeEventListener('pointermove', moveHandlerRef.current); + moveHandlerRef.current = null; + } + if (upHandlerRef.current) { + window.removeEventListener('pointerup', upHandlerRef.current); + upHandlerRef.current = null; + } + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }, []); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + // setPointerCapture can throw on some browsers; window listeners + // below are sufficient on their own. + } + + const onMove = (ev: PointerEvent) => { + const node = containerRef.current; + if (!node) return; + const rect = node.getBoundingClientRect(); + if (rect.width <= 0) return; + // Right panel width = container right edge - cursor X. + const rightWidth = rect.right - ev.clientX; + const pct = (rightWidth / rect.width) * 100; + setWidthPct(pct); + }; + const onUp = () => stopDragging(); + + moveHandlerRef.current = onMove; + upHandlerRef.current = onUp; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + [containerRef, setWidthPct, stopDragging], + ); + + // Safety: if the divider unmounts mid-drag, clean up listeners. + useEffect(() => stopDragging, [stopDragging]); + + return ( + + ); +} + +export default PanelResizeDivider; diff --git a/src/components/file-preview/SkillFileSections.tsx b/src/components/file-preview/SkillFileSections.tsx new file mode 100644 index 0000000..f80779d --- /dev/null +++ b/src/components/file-preview/SkillFileSections.tsx @@ -0,0 +1,166 @@ +/** + * Skill detail dialog file sections. + * + * Loads `loadSkillFiles(baseDir)` and groups the result into + * Docs / Scripts / Hooks / Assets cards (Other is hidden by default). + */ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { cn } from '@/lib/utils'; +import { + EMPTY_SKILL_GROUPS, + isSkillFileGroupsEmpty, + loadSkillFiles, + type SkillFile, + type SkillFileGroups, +} from '@/lib/skill-files'; +import { FilePreviewIcon } from './file-card-utils'; +import { formatFileSize } from './format'; + +export interface SkillFileSectionsProps { + baseDir: string; + onOpen: (file: SkillFile) => void; + className?: string; +} + +export function SkillFileSections({ baseDir, onOpen, className }: SkillFileSectionsProps) { + const { t } = useTranslation('skills'); + const [groups, setGroups] = useState(EMPTY_SKILL_GROUPS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + /* eslint-disable react-hooks/set-state-in-effect -- async fetch with loading/error flags */ + if (!baseDir) { + setGroups(EMPTY_SKILL_GROUPS); + return; + } + setLoading(true); + setError(null); + /* eslint-enable react-hooks/set-state-in-effect */ + loadSkillFiles(baseDir) + .then((next) => { + if (cancelled) return; + setGroups(next); + }) + .catch((err) => { + if (cancelled) return; + setError(String(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [baseDir]); + + if (!baseDir) return null; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t('detail.sections.scanFailed', { defaultValue: '扫描技能目录失败' })} +
+ ); + } + + if (isSkillFileGroupsEmpty(groups)) { + return ( +
+ {t('detail.sections.empty', { defaultValue: '此技能未包含可预览的文件。' })} +
+ ); + } + + return ( +
+ + + + +
+ ); +} + +interface SkillFileSectionProps { + title: string; + description: string; + files: SkillFile[]; + onOpen: (file: SkillFile) => void; +} + +function SkillFileSection({ title, description, files, onOpen }: SkillFileSectionProps) { + if (!files.length) return null; + return ( +
+
+

+ {title} + {files.length} +

+

{description}

+
+
+ {files.map((file) => ( + + ))} +
+
+ ); +} + +export default SkillFileSections; diff --git a/src/components/file-preview/WorkspaceBrowserBody.tsx b/src/components/file-preview/WorkspaceBrowserBody.tsx new file mode 100644 index 0000000..fb9531a --- /dev/null +++ b/src/components/file-preview/WorkspaceBrowserBody.tsx @@ -0,0 +1,516 @@ +/** + * Inline workspace browser body — left tree + right preview. + * + * Strictly scoped to the current agent's `agent.workspace` directory. + * Used by `ArtifactPanel`'s 浏览器 tab (split-pane on the chat page). + */ +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { ChevronRight, FolderOpen, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { cn } from '@/lib/utils'; +import { invokeIpc, readTextFile } from '@/lib/api-client'; +import { supportsInlineDocumentPreview } from '@/lib/generated-files'; +import { + collectInitialExpanded, + findNode, + loadWorkspaceTree, + type WorkspaceTreeNode, +} from '@/lib/workspace-tree'; +import type { AgentSummary } from '@/types/agent'; +import { FilePreviewIcon } from './file-card-utils'; +import { formatFileSize } from './format'; +import MarkdownPreview from './MarkdownPreview'; +import ImageViewer from './ImageViewer'; + +const MonacoViewerLazy = lazy(() => import('./MonacoViewer')); + +export interface WorkspaceBrowserBodyProps { + agent: AgentSummary | null; + /** Used to mark "本轮新增" badges on the tree. */ + runStartedAt?: number | null; + /** Bumping this number triggers a tree reload (e.g. after AI run idles). */ + refreshSignal?: number; + /** Compact mode used inside the side panel (smaller fonts/paddings). */ + compact?: boolean; + /** Left tree column width in px. */ + treeWidth?: number; + /** Optional slot rendered in the toolbar (e.g. close button when used in a Sheet). */ + toolbarTrailing?: React.ReactNode; +} + +type LoadState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; root: WorkspaceTreeNode; truncated: boolean } + | { status: 'error'; message: string }; + +type FileState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; content: string } + | { status: 'tooLarge' } + | { status: 'binary' } + | { status: 'unsupported' } + | { status: 'error'; message: string }; + +export function WorkspaceBrowserBody({ + agent, + runStartedAt, + refreshSignal, + compact = false, + treeWidth, + toolbarTrailing, +}: WorkspaceBrowserBodyProps) { + const { t } = useTranslation('chat'); + const [state, setState] = useState({ status: 'idle' }); + const [expanded, setExpanded] = useState>(() => new Set()); + const [selectedRel, setSelectedRel] = useState(null); + const [fileState, setFileState] = useState({ status: 'idle' }); + const [refreshTick, setRefreshTick] = useState(0); + const [showHidden, setShowHidden] = useState(false); + + const workspace = agent?.workspace ?? ''; + + const reload = useCallback(() => setRefreshTick((v) => v + 1), []); + + // Reset selection when the agent changes. + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- intentional reset on agent switch */ + setSelectedRel(null); + setFileState({ status: 'idle' }); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [agent?.id]); + + useEffect(() => { + if (!workspace) return; + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- async tree fetch + setState({ status: 'loading' }); + loadWorkspaceTree(workspace, { + runStartedAt: runStartedAt ?? null, + includeHidden: showHidden, + }) + .then((res) => { + if (cancelled) return; + if (!res) { + setState({ status: 'error', message: 'load' }); + return; + } + setState({ status: 'ready', root: res.root, truncated: res.truncated }); + setExpanded((prev) => (prev.size > 0 ? prev : collectInitialExpanded(res.root, 1))); + }) + .catch((err) => { + if (cancelled) return; + setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + }); + return () => { + cancelled = true; + }; + }, [workspace, runStartedAt, refreshTick, showHidden, refreshSignal]); + + const selectedNode = useMemo(() => { + if (!selectedRel || state.status !== 'ready') return null; + return findNode(state.root, selectedRel); + }, [selectedRel, state]); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- selection-driven loader */ + if (!selectedNode || selectedNode.isDir) { + setFileState({ status: 'idle' }); + return; + } + const node = selectedNode; + if (node.contentType === 'document' && !supportsInlineDocumentPreview(node.ext ?? '')) { + setFileState({ status: 'unsupported' }); + return; + } + if (node.contentType === 'snapshot' || node.contentType === 'video' || node.contentType === 'audio') { + setFileState({ status: 'ready', content: '' }); + return; + } + let cancelled = false; + setFileState({ status: 'loading' }); + /* eslint-enable react-hooks/set-state-in-effect */ + readTextFile(node.absPath) + .then((res) => { + if (cancelled) return; + if (!res.ok) { + if (res.error === 'tooLarge') { + setFileState({ status: 'tooLarge' }); + return; + } + if (res.error === 'binary') { + setFileState({ status: 'binary' }); + return; + } + setFileState({ status: 'error', message: String(res.error ?? 'unknown') }); + return; + } + setFileState({ status: 'ready', content: res.content ?? '' }); + }) + .catch((err) => { + if (cancelled) return; + setFileState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + }); + return () => { + cancelled = true; + }; + }, [selectedNode]); + + const handleOpenWorkspaceInFinder = useCallback(() => { + if (!workspace) return; + invokeIpc('shell:openPath', workspace).catch(() => { + toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示')); + }); + }, [workspace, t]); + + const toggleNode = useCallback((relPath: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(relPath)) next.delete(relPath); + else next.add(relPath); + return next; + }); + }, []); + + const renderTree = () => { + if (state.status === 'loading' || state.status === 'idle') { + return ( +
+ +
+ ); + } + if (state.status === 'error') { + return ( +
+ {state.message === 'outsideSandbox' + ? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取') + : t('workspace.empty', '工作空间为空或无法访问')} +
+ ); + } + return ( +
+
+ {t('workspace.title', '工作空间')} + {agent?.name ? · {agent.name} : null} +
+ setSelectedRel(rel)} + /> + {state.truncated && ( +
+ {t('workspace.truncated', '目录过大,已截断显示 5000 个节点')} +
+ )} +
+ ); + }; + + const renderBody = () => { + if (!selectedNode || selectedNode.isDir) { + return ( +
+ {t('workspace.pickFile', '从左侧选择一个文件预览')} +
+ ); + } + if (selectedNode.contentType === 'snapshot') { + return ; + } + if (fileState.status === 'loading' || fileState.status === 'idle') { + return ( +
+ +
+ ); + } + if (fileState.status === 'tooLarge') { + return ( +
+

{t('filePreview.errors.tooLarge', '文件过大,已禁用预览')}

+ +
+ ); + } + if (fileState.status === 'binary') { + return ( +
+

{t('filePreview.errors.binary', '二进制文件不支持文本预览')}

+ +
+ ); + } + if (fileState.status === 'error') { + const errMsg = fileState.message; + const hint = errMsg === 'outsideSandbox' + ? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取') + : errMsg === 'notFound' + ? t('filePreview.errors.notFound', '文件不存在') + : errMsg; + return ( +
+ {hint} +
+ ); + } + if (fileState.status === 'unsupported') { + return ( +
+
+

+ {t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')} +

+

+ {t( + 'filePreview.errors.unsupportedFormatHint', + '当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。', + )} +

+
+ +
+ ); + } + + if (selectedNode.contentType === 'document') { + return ( +
+ +
+ ); + } + + return ( + + + + } + > + + + ); + }; + + return ( +
+
+
+

+ {t('workspace.title', '工作空间')} + {agent?.name ? · {agent.name} : null} +

+ {workspace && !compact ? ( + + {workspace} + + ) : null} +
+
+ + + + {toolbarTrailing} +
+
+
+ +
+ {selectedNode && !selectedNode.isDir && ( +
+
+ + {selectedNode.relPath || selectedNode.name} + {selectedNode.isFresh && ( + + {t('workspace.freshBadge', '本轮新增')} + + )} +
+ {formatFileSize(selectedNode.size ?? 0)} +
+ )} +
{renderBody()}
+
+
+
+ ); +} + +interface FileTreeNodeListProps { + nodes: WorkspaceTreeNode[]; + depth: number; + expanded: Set; + selectedRel: string | null; + onToggle: (relPath: string) => void; + onSelect: (relPath: string) => void; +} + +function FileTreeNodeList({ nodes, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeListProps) { + return ( +
    + {nodes.map((node) => ( + + ))} +
+ ); +} + +interface FileTreeNodeRowProps extends Omit { + node: WorkspaceTreeNode; +} + +function FileTreeNodeRow({ node, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeRowProps) { + const isOpen = node.isDir && expanded.has(node.relPath); + const isSelected = selectedRel === node.relPath; + const indent = 12 + depth * 14; + + if (node.isDir) { + return ( +
  • + + {isOpen && node.children && node.children.length > 0 && ( + + )} +
  • + ); + } + + return ( +
  • + +
  • + ); +} + +export default WorkspaceBrowserBody; diff --git a/src/components/file-preview/build-preview-target.ts b/src/components/file-preview/build-preview-target.ts new file mode 100644 index 0000000..348b756 --- /dev/null +++ b/src/components/file-preview/build-preview-target.ts @@ -0,0 +1,20 @@ +/** + * Build a `FilePreviewTarget` from a raw filesystem path, applying + * mime / content-type defaults. Lives outside `FilePreviewOverlay.tsx` + * so importing the helper doesn't bring in the Sheet/Monaco component + * graph (and so React Fast Refresh stays happy). + */ +import { classifyFileExt, extnameOf, getMimeTypeForExt } from '@/lib/generated-files'; +import type { FilePreviewTarget } from './FilePreviewOverlay'; + +export function buildPreviewTarget(filePath: string, fileName?: string): FilePreviewTarget { + const ext = extnameOf(filePath); + const name = fileName || (filePath.replace(/\\/g, '/').split('/').pop() ?? filePath); + return { + filePath, + fileName: name, + ext, + mimeType: getMimeTypeForExt(ext), + contentType: classifyFileExt(ext), + }; +} diff --git a/src/components/file-preview/file-card-utils.tsx b/src/components/file-preview/file-card-utils.tsx new file mode 100644 index 0000000..e25b241 --- /dev/null +++ b/src/components/file-preview/file-card-utils.tsx @@ -0,0 +1,39 @@ +/** + * Shared icon picker for file cards (chat generated files + skill detail + * dialog) and the workspace tree, so all panels render with consistent + * iconography. + */ +import { File, FileArchive, FileCode, FileText, Film, ImageIcon, Music } from 'lucide-react'; +import type { FileContentType } from '@/lib/generated-files'; + +export interface FilePreviewIconProps { + contentType?: FileContentType; + mimeType?: string; + ext?: string; + className?: string; +} + +export function FilePreviewIcon({ contentType, mimeType, ext, className }: FilePreviewIconProps) { + if (contentType === 'snapshot' || (mimeType && mimeType.startsWith('image/'))) { + return ; + } + if (contentType === 'video' || (mimeType && mimeType.startsWith('video/'))) { + return ; + } + if (contentType === 'audio' || (mimeType && mimeType.startsWith('audio/'))) { + return ; + } + if (contentType === 'document') { + return ; + } + if (contentType === 'code') { + return ; + } + if (mimeType === 'application/json' || mimeType === 'application/xml' || (mimeType?.startsWith('text/'))) { + return ; + } + if (ext && /\.(zip|tar|gz|7z|rar)$/i.test(ext)) { + return ; + } + return ; +} diff --git a/src/components/file-preview/format.ts b/src/components/file-preview/format.ts new file mode 100644 index 0000000..2206513 --- /dev/null +++ b/src/components/file-preview/format.ts @@ -0,0 +1,12 @@ +/** + * Tiny formatting helpers shared across file preview components. + * Kept in a non-`.tsx` module so React Fast Refresh doesn't bail on + * components that import them. + */ +export function formatFileSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/src/components/file-preview/types.ts b/src/components/file-preview/types.ts new file mode 100644 index 0000000..85a149d --- /dev/null +++ b/src/components/file-preview/types.ts @@ -0,0 +1,43 @@ +/** + * Shared types for the file preview pipeline. + * + * Lives outside `FilePreviewOverlay.tsx` so callers (chat panel, workspace + * tree, skills page, …) can import the type without pulling in the Sheet / + * Monaco component graph. + */ +import type { FileContentType, FileEditOp, GeneratedFileBaseline } from '@/lib/generated-files'; + +export interface FilePreviewTarget { + filePath: string; + fileName: string; + ext: string; + mimeType: string; + contentType: FileContentType; + /** + * From chat extraction only. Drives the badge in the changes list and is + * not used by the diff view itself (which derives "before/after" from + * `fullContent` / `edits` directly, WorkBuddy-style). + */ + action?: 'created' | 'modified'; + /** + * Full new content of the file when the tool payload provides it (Write + * family). + */ + fullContent?: string; + /** + * Content of the file *before* the AI's write, captured from disk when + * the tool_use was first detected in the stream. + * + * - `ok` → render a real before/after diff + * - `missing` → render a new-file diff (empty left pane) + * - `unavailable`→ avoid pretending the file was new; show diff unavailable + */ + baseline?: GeneratedFileBaseline; + /** + * Edit operations from Edit / StrReplace / MultiEdit. The diff view + * renders these directly as a snippet diff (left = joined `op.old`, + * right = joined `op.new`) — exactly what the AI changed, no disk + * reads, no reverse-application. + */ + edits?: FileEditOp[]; +} diff --git a/src/components/file-preview/workspace-browser-config.ts b/src/components/file-preview/workspace-browser-config.ts new file mode 100644 index 0000000..2735fc3 --- /dev/null +++ b/src/components/file-preview/workspace-browser-config.ts @@ -0,0 +1,7 @@ +/** + * Temporary feature gate for the Chat workspace browser UI. + * + * Kept as a single constant so we can re-enable the entry points later + * without ripping out the underlying implementation. + */ +export const WORKSPACE_BROWSER_ENABLED = false; diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 0828f66..4df8603 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -13,7 +13,95 @@ "noLogs": "(No logs available yet)", "toolbar": { "refresh": "Refresh chat", - "currentAgent": "Talking to {{agent}}" + "currentAgent": "Talking to {{agent}}", + "workspace": "Workspace" + }, + "generatedFiles": { + "title": "File changes ({{count}})", + "created": "Created", + "modified": "Modified", + "viewAll": "View all changes" + }, + "runError": { + "title": "Model call failed" + }, + "artifactPanel": { + "tabs": { + "changes": "Changes", + "preview": "Preview", + "browser": "Workspace" + }, + "actions": { + "back": "Back to list" + }, + "changes": { + "heading": "File changes ({{count}})", + "empty": "No file changes in this conversation yet", + "selectFileHint": "Click a file card in the chat or “View changes” to open a diff." + }, + "preview": { + "emptyTitle": "No file selected", + "emptyHint": "Click a file card in the chat first; the selected file will preview here." + } + }, + "filePreview": { + "tabs": { + "source": "Source", + "preview": "Preview", + "changes": "Changes", + "info": "Info" + }, + "actions": { + "save": "Save", + "saving": "Saving...", + "revert": "Revert", + "openInFinder": "Reveal in Finder", + "close": "Close" + }, + "info": { + "path": "Path", + "size": "Size", + "type": "Type" + }, + "toast": { + "saved": "Saved to disk", + "saveFailed": "Save failed: {{error}}" + }, + "errors": { + "outsideSandbox": "Path outside sandbox — access denied", + "outsideSandboxTitle": "This file lives outside the sandbox", + "outsideSandboxHint": "For safety, ClawX only previews files in ~/.openclaw, app resources, and bundled skill directories. You can still open it in Finder.", + "readOnlyRoot": "File lives in a read-only location (e.g. bundled skill) and cannot be modified", + "tooLarge": "File too large ({{size}}); preview disabled", + "binary": "Binary file — text preview unavailable", + "notFound": "File not found", + "loadFailed": "Load failed: {{error}}", + "noPreview": "No preview available for this file", + "unsupportedFormatTitle": "This file format is not supported for built-in preview or diff", + "unsupportedFormatHint": "Built-in preview and diff currently support directly readable text/Markdown-style files only. Open the file in Finder instead.", + "openInFinderFailed": "Could not reveal file" + }, + "diff": { + "noChanges": "No changes to display", + "newFile": "New file — no original to compare", + "tooLarge": "File too large — diff view disabled, switch to the Source tab to view the full content", + "unavailable": "The current chat did not capture a precise baseline for this file, so no diff is available.", + "unavailableHint": "Use the Preview tab for the current file; use Git or another VCS for an exact diff." + } + }, + "workspace": { + "title": "Workspace", + "freshBadge": "Updated this run", + "pickFile": "Select a file from the left to preview", + "empty": "Workspace is empty or inaccessible", + "truncated": "Directory truncated — only the first 5000 entries shown", + "actions": { + "refresh": "Refresh", + "openRootInFinder": "Reveal workspace in Finder", + "toggleHidden": "Toggle hidden files", + "showHidden": "Show hidden files", + "hideHidden": "Hide hidden files" + } }, "taskPanel": { "eyebrow": "Run View", diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 363bcf0..8d136a4 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -55,7 +55,20 @@ "configurable": "Configurable", "uninstall": "Uninstall", "enable": "Enable", - "disable": "Disable" + "disable": "Disable", + "sections": { + "title": "Contents", + "docs": "Docs", + "docsDesc": "SKILL.md and supporting docs", + "scripts": "Scripts", + "scriptsDesc": "Executable scripts and commands", + "hooks": "Hooks", + "hooksDesc": "Lifecycle hooks injected into OpenClaw", + "assets": "Assets", + "assetsDesc": "Templates, references and static files", + "empty": "This skill has no previewable files.", + "scanFailed": "Failed to scan the skill directory" + } }, "source": { "badge": { diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 29bea0b..5b56b37 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -13,7 +13,95 @@ "noLogs": "(ログはまだありません)", "toolbar": { "refresh": "チャットを更新", - "currentAgent": "現在の会話相手: {{agent}}" + "currentAgent": "現在の会話相手: {{agent}}", + "workspace": "ワークスペース" + }, + "generatedFiles": { + "title": "ファイルの変更({{count}} 件)", + "created": "新規", + "modified": "更新", + "viewAll": "ファイル変更を見る" + }, + "runError": { + "title": "モデル呼び出しに失敗しました" + }, + "artifactPanel": { + "tabs": { + "changes": "変更", + "preview": "プレビュー", + "browser": "ワークスペース" + }, + "actions": { + "back": "一覧に戻る" + }, + "changes": { + "heading": "ファイルの変更({{count}} 件)", + "empty": "この会話にはまだファイルの変更がありません", + "selectFileHint": "会話内のファイルカード、または「変更を表示」をクリックして diff を開きます。" + }, + "preview": { + "emptyTitle": "ファイルが選択されていません", + "emptyHint": "まず会話内のファイルカードをクリックしてファイルを選択すると、ここにプレビューが表示されます。" + } + }, + "filePreview": { + "tabs": { + "source": "ソース", + "preview": "プレビュー", + "changes": "変更", + "info": "情報" + }, + "actions": { + "save": "保存", + "saving": "保存中...", + "revert": "元に戻す", + "openInFinder": "Finder で表示", + "close": "閉じる" + }, + "info": { + "path": "パス", + "size": "サイズ", + "type": "種類" + }, + "toast": { + "saved": "ディスクに保存しました", + "saveFailed": "保存に失敗しました: {{error}}" + }, + "errors": { + "outsideSandbox": "サンドボックス外のパスのため拒否しました", + "outsideSandboxTitle": "このファイルはサンドボックス外にあります", + "outsideSandboxHint": "安全のため、ClawX は ~/.openclaw、アプリ内リソース、内蔵スキルのディレクトリにあるファイルだけをプレビューします。Finder では引き続き開けます。", + "readOnlyRoot": "このファイルは読み取り専用領域(内蔵スキルなど)にあり、変更できません", + "tooLarge": "ファイルが大きすぎます({{size}})。プレビューを無効化しました", + "binary": "バイナリファイルのためテキストプレビューはできません", + "notFound": "ファイルが見つかりません", + "loadFailed": "読み込みに失敗しました: {{error}}", + "noPreview": "このファイルにはプレビューがありません", + "unsupportedFormatTitle": "このファイル形式は内蔵プレビューまたは差分表示に未対応です", + "unsupportedFormatHint": "現在、内蔵プレビューと差分表示は直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。Finder で開いてください。", + "openInFinderFailed": "Finder で表示できません" + }, + "diff": { + "noChanges": "表示する変更はありません", + "newFile": "新規ファイルのため比較対象がありません", + "tooLarge": "ファイルが大きすぎるため diff ビューは無効化されました。完全な内容は「ソース」タブで確認してください", + "unavailable": "この会話ではこのファイルの正確な変更ベースラインを取得できなかったため、差分を生成できません。", + "unavailableHint": "「プレビュー」で現在の内容を確認するか、Git などで正確な差分を参照してください。" + } + }, + "workspace": { + "title": "ワークスペース", + "freshBadge": "今回の実行で更新", + "pickFile": "左側のファイルを選択してプレビュー", + "empty": "ワークスペースが空、またはアクセスできません", + "truncated": "ディレクトリが大きいため、5000 件までで切り詰めました", + "actions": { + "refresh": "更新", + "openRootInFinder": "ルートを Finder で表示", + "toggleHidden": "隠しファイル表示切替", + "showHidden": "隠しファイルを表示", + "hideHidden": "隠しファイルを隠す" + } }, "taskPanel": { "eyebrow": "実行ビュー", diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json index d8d73c6..5b43548 100644 --- a/src/i18n/locales/ja/skills.json +++ b/src/i18n/locales/ja/skills.json @@ -55,7 +55,20 @@ "configurable": "設定可能", "uninstall": "アンインストール", "enable": "有効化", - "disable": "無効化" + "disable": "無効化", + "sections": { + "title": "コンテンツ", + "docs": "ドキュメント", + "docsDesc": "SKILL.md と関連ドキュメント", + "scripts": "スクリプト", + "scriptsDesc": "実行可能なスクリプトとコマンド", + "hooks": "Hooks", + "hooksDesc": "OpenClaw のライフサイクルに注入されるフック", + "assets": "アセット", + "assetsDesc": "テンプレート、参考資料、静的ファイル", + "empty": "このスキルにはプレビュー可能なファイルがありません。", + "scanFailed": "スキルディレクトリのスキャンに失敗しました" + } }, "source": { "badge": { diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index ea7d944..05fda6f 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -13,7 +13,95 @@ "noLogs": "(Журналы ещё недоступны)", "toolbar": { "refresh": "Обновить чат", - "currentAgent": "Общение с {{agent}}" + "currentAgent": "Общение с {{agent}}", + "workspace": "Рабочая область" + }, + "generatedFiles": { + "title": "Изменения файлов ({{count}})", + "created": "Создано", + "modified": "Изменено", + "viewAll": "Все изменения" + }, + "runError": { + "title": "Ошибка вызова модели" + }, + "artifactPanel": { + "tabs": { + "changes": "Изменения", + "preview": "Просмотр", + "browser": "Рабочая область" + }, + "actions": { + "back": "Назад к списку" + }, + "changes": { + "heading": "Изменения файлов ({{count}})", + "empty": "В этом разговоре пока нет изменений файлов", + "selectFileHint": "Нажмите на карточку файла в чате или «Просмотр изменений», чтобы открыть diff." + }, + "preview": { + "emptyTitle": "Файл не выбран", + "emptyHint": "Сначала откройте файл через карточку в чате — выбранный файл появится в этом предпросмотре." + } + }, + "filePreview": { + "tabs": { + "source": "Исходник", + "preview": "Предпросмотр", + "changes": "Изменения", + "info": "Информация" + }, + "actions": { + "save": "Сохранить", + "saving": "Сохранение...", + "revert": "Откатить", + "openInFinder": "Показать в Finder", + "close": "Закрыть" + }, + "info": { + "path": "Путь", + "size": "Размер", + "type": "Тип" + }, + "toast": { + "saved": "Сохранено на диск", + "saveFailed": "Ошибка сохранения: {{error}}" + }, + "errors": { + "outsideSandbox": "Путь вне песочницы — доступ запрещён", + "outsideSandboxTitle": "Файл находится вне песочницы", + "outsideSandboxHint": "В целях безопасности ClawX показывает предпросмотр только для файлов в ~/.openclaw, ресурсах приложения и встроенных каталогах навыков. Файл можно открыть в Finder.", + "readOnlyRoot": "Файл находится в режиме только для чтения (например, встроенный навык) и не может быть изменён", + "tooLarge": "Файл слишком большой ({{size}}); предпросмотр отключён", + "binary": "Бинарный файл — текстовый предпросмотр недоступен", + "notFound": "Файл не найден", + "loadFailed": "Ошибка загрузки: {{error}}", + "noPreview": "Для этого файла предпросмотр недоступен", + "unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр или diff", + "unsupportedFormatHint": "Встроенный предпросмотр и diff сейчас поддерживают только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через Finder.", + "openInFinderFailed": "Не удалось показать файл" + }, + "diff": { + "noChanges": "Нет изменений для отображения", + "newFile": "Новый файл — нет оригинала для сравнения", + "tooLarge": "Файл слишком большой — режим diff отключён, откройте вкладку «Источник» для просмотра содержимого", + "unavailable": "В этом чате не удалось зафиксировать точную базовую версию этого файла, поэтому diff недоступен.", + "unavailableHint": "Откройте вкладку «Просмотр» для текущего содержимого; точное сравнение — в Git или другой системе." + } + }, + "workspace": { + "title": "Рабочая область", + "freshBadge": "Обновлено в этот раз", + "pickFile": "Выберите файл слева для предпросмотра", + "empty": "Рабочая область пуста или недоступна", + "truncated": "Каталог обрезан — показаны первые 5000 узлов", + "actions": { + "refresh": "Обновить", + "openRootInFinder": "Показать корень в Finder", + "toggleHidden": "Скрытые файлы", + "showHidden": "Показать скрытые файлы", + "hideHidden": "Скрыть скрытые файлы" + } }, "taskPanel": { "eyebrow": "Вид выполнения", diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json index 204e664..03e599d 100644 --- a/src/i18n/locales/ru/skills.json +++ b/src/i18n/locales/ru/skills.json @@ -55,7 +55,20 @@ "configurable": "Настраиваемый", "uninstall": "Удалить", "enable": "Включить", - "disable": "Выключить" + "disable": "Выключить", + "sections": { + "title": "Содержимое", + "docs": "Документация", + "docsDesc": "SKILL.md и сопутствующие документы", + "scripts": "Скрипты", + "scriptsDesc": "Исполняемые скрипты и команды", + "hooks": "Hooks", + "hooksDesc": "Хуки в жизненный цикл OpenClaw", + "assets": "Ресурсы", + "assetsDesc": "Шаблоны, справочники и статические файлы", + "empty": "В этом скилле нет файлов для предпросмотра.", + "scanFailed": "Не удалось просканировать каталог скилла" + } }, "source": { "badge": { diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 0930317..3e37e46 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -13,7 +13,95 @@ "noLogs": "(暂无日志)", "toolbar": { "refresh": "刷新聊天", - "currentAgent": "当前对话对象:{{agent}}" + "currentAgent": "当前对话对象:{{agent}}", + "workspace": "工作空间" + }, + "generatedFiles": { + "title": "文件变更({{count}} 个)", + "created": "新增", + "modified": "修改", + "viewAll": "查看文件变更" + }, + "runError": { + "title": "模型调用失败" + }, + "artifactPanel": { + "tabs": { + "changes": "变更", + "preview": "预览", + "browser": "工作空间" + }, + "actions": { + "back": "返回列表" + }, + "changes": { + "heading": "文件变更({{count}} 个)", + "empty": "本会话尚无文件变更", + "selectFileHint": "请点击对话中的文件卡片,或「查看文件变更」打开 diff。" + }, + "preview": { + "emptyTitle": "尚未选择文件", + "emptyHint": "请先点击对话里的文件卡片,侧栏会选中该文件后在此预览。" + } + }, + "filePreview": { + "tabs": { + "source": "源码", + "preview": "预览", + "changes": "变更", + "info": "信息" + }, + "actions": { + "save": "保存", + "saving": "保存中...", + "revert": "撤销", + "openInFinder": "在 Finder 中显示", + "close": "关闭" + }, + "info": { + "path": "路径", + "size": "大小", + "type": "类型" + }, + "toast": { + "saved": "已保存到磁盘", + "saveFailed": "保存失败:{{error}}" + }, + "errors": { + "outsideSandbox": "路径越界,已拒绝访问", + "outsideSandboxTitle": "此文件位于沙盒外", + "outsideSandboxHint": "出于安全考虑,ClawX 仅允许预览 ~/.openclaw、应用资源以及内置技能目录中的文件。可在 Finder 中查看。", + "readOnlyRoot": "该文件位于只读位置(如内置技能),无法修改", + "tooLarge": "文件过大({{size}}),已禁用预览", + "binary": "二进制文件不支持文本预览", + "notFound": "文件不存在", + "loadFailed": "加载失败:{{error}}", + "noPreview": "该文件没有预览", + "unsupportedFormatTitle": "此文件格式暂不支持内置预览或变更", + "unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。", + "openInFinderFailed": "无法在 Finder 中显示" + }, + "diff": { + "noChanges": "没有可显示的变更", + "newFile": "这是新增文件,无对比内容", + "tooLarge": "文件过大,已禁用 diff 视图,请到「源码」标签查看完整内容", + "unavailable": "本会话没有抓到这个文件的精确变更基线,无法生成 diff。", + "unavailableHint": "可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。" + } + }, + "workspace": { + "title": "工作空间", + "freshBadge": "本轮新增", + "pickFile": "从左侧选择一个文件预览", + "empty": "工作空间为空或无法访问", + "truncated": "目录过大,已截断显示前 5000 个节点", + "actions": { + "refresh": "刷新", + "openRootInFinder": "在 Finder 中显示根目录", + "toggleHidden": "显示/隐藏隐藏文件", + "showHidden": "显示隐藏文件", + "hideHidden": "隐藏隐藏文件" + } }, "taskPanel": { "eyebrow": "运行视图", diff --git a/src/i18n/locales/zh/skills.json b/src/i18n/locales/zh/skills.json index f4bdb01..b8e8020 100644 --- a/src/i18n/locales/zh/skills.json +++ b/src/i18n/locales/zh/skills.json @@ -55,7 +55,20 @@ "configurable": "可配置", "uninstall": "卸载", "enable": "启用", - "disable": "禁用" + "disable": "禁用", + "sections": { + "title": "内容", + "docs": "文档", + "docsDesc": "SKILL.md 与说明文档", + "scripts": "脚本", + "scriptsDesc": "可执行的脚本与命令", + "hooks": "Hooks", + "hooksDesc": "注入到 OpenClaw 生命周期的钩子", + "assets": "资源", + "assetsDesc": "模板、参考资料与静态资源", + "empty": "此技能未包含可预览的文件。", + "scanFailed": "扫描技能目录失败" + } }, "source": { "badge": { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 5163765..9536304 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1061,3 +1061,100 @@ export async function invokeIpcWithRetry( throw normalizeAppError(lastError); } + +// ── File preview wrappers ───────────────────────────────────────────── +// +// Thin typed wrappers over the sandboxed file:* IPC channels exposed by +// the main process. Callers stay free of `invokeIpc('file:readText', ...)` +// boilerplate and get exhaustive error codes. + +export type FilePreviewError = + | 'outsideSandbox' + | 'readOnlyRoot' + | 'tooLarge' + | 'binary' + | 'notFound' + | 'notDirectory' + | 'invalidContent' + | string; + +export interface ReadTextFileResult { + ok: boolean; + content?: string; + mimeType?: string; + size?: number; + /** + * Set by the main process when the resolved path lives in a read-only + * root (bundled skill, app resources, …). The renderer should disable + * editing affordances when this is true even if the caller passes + * `readOnly={false}`. + */ + readOnly?: boolean; + error?: FilePreviewError; +} + +export interface WriteTextFileResult { + ok: boolean; + error?: FilePreviewError; +} + +export interface StatFileResult { + ok: boolean; + size?: number; + mtime?: number; + isFile?: boolean; + isDir?: boolean; + readOnly?: boolean; + error?: FilePreviewError; +} + +export interface ListDirEntry { + name: string; + path: string; + isDir: boolean; + size: number; +} + +export interface ListDirResult { + ok: boolean; + entries?: ListDirEntry[]; + error?: FilePreviewError; +} + +export interface TreeNode { + name: string; + relPath: string; + absPath: string; + isDir: boolean; + size?: number; + mtime?: number; + children?: TreeNode[]; +} + +export interface ListTreeOptions { + maxDepth?: number; + maxNodes?: number; + includeHidden?: boolean; +} + +export interface ListTreeResult { + ok: boolean; + root?: TreeNode; + truncated?: boolean; + error?: FilePreviewError; +} + +export const readTextFile = (path: string): Promise => + invokeIpc('file:readText', path); + +export const writeTextFile = (path: string, content: string): Promise => + invokeIpc('file:writeText', path, content); + +export const statFile = (path: string): Promise => + invokeIpc('file:stat', path); + +export const listDir = (path: string): Promise => + invokeIpc('file:listDir', path); + +export const listTree = (path: string, opts?: ListTreeOptions): Promise => + invokeIpc('file:listTree', path, opts); diff --git a/src/lib/generated-files.ts b/src/lib/generated-files.ts new file mode 100644 index 0000000..a34f1b2 --- /dev/null +++ b/src/lib/generated-files.ts @@ -0,0 +1,450 @@ +/** + * Generated files extraction. + * + * Inspects a run segment of chat messages (the slice from a user trigger + * to its terminating assistant reply) and surfaces files the AI wrote / + * edited via tool calls. Used by `GeneratedFilesPanel` to render inline + * file cards under each run, and by `FilePreviewBody` / `ArtifactPanel` + * to power the diff view. + */ +import { diffLines } from 'diff'; +import type { ContentBlock, RawMessage } from '@/stores/chat'; + +export type FileContentType = + | 'snapshot' + | 'code' + | 'document' + | 'video' + | 'audio' + | 'other'; + +/** A single (old_string -> new_string) replacement extracted from an edit tool. */ +export interface FileEditOp { + old: string; + new: string; +} + +export type GeneratedFileBaseline = + | { status: 'ok'; content: string } + | { status: 'missing' } + | { status: 'unavailable'; reason: string }; + +export interface FileLineStats { + added: number; + removed: number; +} + +export interface GeneratedFile { + filePath: string; + fileName: string; + ext: string; + mimeType: string; + contentType: FileContentType; + action: 'created' | 'modified'; + /** + * Full new content of the file when known (only set by `Write`-family + * tools that provide the whole document in their input). + */ + fullContent?: string; + /** + * Ordered list of edits applied to this file during the run (Edit / + * StrReplace / MultiEdit). The diff view renders these directly as a + * snippet diff (joined `old` vs joined `new`), matching WorkBuddy / + * Codex behaviour. + */ + edits?: FileEditOp[]; + /** + * File content captured immediately before a Write-family tool executed. + * `missing` means the file did not exist yet. `unavailable` means the + * renderer could not read the existing file precisely enough to build a + * trustworthy before/after diff (outside sandbox, binary, too large, ...). + */ + baseline?: GeneratedFileBaseline; + /** Index of the latest tool call that touched this file (for stable ordering). */ + lastSeenIndex: number; +} + +/** Visual separator between multiple edit hunks. */ +const SNIPPET_SEPARATOR = '\n\n'; + +/** + * True when the chat extraction captured enough tool payload to render a + * diff (Write `fullContent` and/or non-empty Edit ops). Entries without + * this should not appear in generated-file UIs. + */ +export function generatedFileHasDiffPayload(file: Pick): boolean { + if (file.fullContent != null) return true; + if (file.edits?.length) { + return file.edits.some((op) => (op.old ?? '') !== '' || (op.new ?? '') !== ''); + } + return false; +} + +const WRITE_TOOLS = new Set([ + 'Write', + 'write_file', + 'create_file', + 'WriteFile', + 'createFile', + 'write', +]); + +const EDIT_TOOLS = new Set([ + 'Edit', + 'edit', + 'edit_file', + 'EditFile', + 'StrReplace', + 'str_replace', + 'str_replace_editor', + 'MultiEdit', + 'multi_edit', + 'multiEdit', +]); + +const FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path']; + +/** Best-effort detector that mirrors the buckets WorkBuddy uses internally. */ +const SNAPSHOT_EXTS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico', +]); +const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']); +const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a']); +const DOCUMENT_EXTS = new Set([ + '.md', '.markdown', '.txt', '.rst', '.adoc', + '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', +]); +const TEXT_DOCUMENT_EXTS = new Set([ + '.md', '.markdown', '.txt', '.rst', '.adoc', +]); +const CODE_EXTS = new Set([ + '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', + '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', + '.c', '.cc', '.cpp', '.h', '.hpp', '.cs', + '.json', '.yaml', '.yml', '.toml', '.xml', + '.sh', '.bash', '.zsh', '.ps1', + '.html', '.htm', '.css', '.scss', '.sass', '.less', + '.sql', '.lua', '.r', '.dart', +]); + +const EXT_MIME_MAP: Record = { + '.md': 'text/markdown', + '.txt': 'text/plain', + '.json': 'application/json', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.toml': 'application/toml', + '.xml': 'application/xml', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.py': 'text/x-python', + '.sh': 'text/x-shellscript', + '.bash': 'text/x-shellscript', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + '.pdf': 'application/pdf', + '.csv': 'text/csv', +}; + +export function getMimeTypeForExt(ext: string): string { + return EXT_MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream'; +} + +export function classifyFileExt(ext: string): FileContentType { + const lower = ext.toLowerCase(); + if (SNAPSHOT_EXTS.has(lower)) return 'snapshot'; + if (VIDEO_EXTS.has(lower)) return 'video'; + if (AUDIO_EXTS.has(lower)) return 'audio'; + if (DOCUMENT_EXTS.has(lower)) return 'document'; + if (CODE_EXTS.has(lower)) return 'code'; + return 'other'; +} + +export function supportsInlineDocumentPreview(ext: string): boolean { + return TEXT_DOCUMENT_EXTS.has(ext.toLowerCase()); +} + +export function supportsInlineDiff(file: Pick): boolean { + if (file.contentType === 'document') return supportsInlineDocumentPreview(file.ext); + if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') return false; + return true; +} + +export function basenameOf(path: string): string { + if (!path) return ''; + const norm = path.replace(/\\/g, '/'); + const last = norm.lastIndexOf('/'); + return last >= 0 ? norm.slice(last + 1) : norm; +} + +export function extnameOf(path: string): string { + const name = basenameOf(path); + const dot = name.lastIndexOf('.'); + if (dot <= 0) return ''; + return name.slice(dot); +} + +function asRecord(input: unknown): Record | null { + if (!input || typeof input !== 'object') return null; + return input as Record; +} + +function pickFilePath(input: unknown): string | null { + const rec = asRecord(input); + if (!rec) return null; + for (const key of FILE_PATH_KEYS) { + const value = rec[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return null; +} + +function pickWriteContent(input: unknown): string | undefined { + const rec = asRecord(input); + if (!rec) return undefined; + for (const key of [ + 'content', + 'contents', + 'text', + 'body', + 'data', + 'new_content', + 'new_string', + 'newString', + 'string', + 'source', + ]) { + const value = rec[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +// Aliases mirror WorkBuddy's tool-arg normaliser so we accept whichever +// naming convention the agent emits (Codex, Claude, Cursor, Cline …). +const OLD_KEYS = [ + 'old_string', 'oldString', 'old_str', 'oldStr', + 'old_text', 'oldText', + 'old', 'oldContent', 'before', 'find', 'search', +]; +const NEW_KEYS = [ + 'new_string', 'newString', 'new_str', 'newStr', + 'new_text', 'newText', + 'new', 'newContent', 'after', 'replace', 'replacement', +]; + +function pickStringByKeys(rec: Record, keys: string[]): string | undefined { + for (const k of keys) { + const v = rec[k]; + if (typeof v === 'string') return v; + } + return undefined; +} + +function pickEditOps(input: unknown): FileEditOp[] { + const rec = asRecord(input); + if (!rec) return []; + const ops: FileEditOp[] = []; + const singleOld = pickStringByKeys(rec, OLD_KEYS); + const singleNew = pickStringByKeys(rec, NEW_KEYS); + if (singleOld !== undefined || singleNew !== undefined) { + ops.push({ old: singleOld ?? '', new: singleNew ?? '' }); + } + const edits = rec.edits; + if (Array.isArray(edits)) { + for (const edit of edits as Array>) { + const o = pickStringByKeys(edit, OLD_KEYS) ?? ''; + const n = pickStringByKeys(edit, NEW_KEYS) ?? ''; + if (o !== '' || n !== '') ops.push({ old: o, new: n }); + } + } + return ops; +} + +function determineWriteAction( + existing: GeneratedFile | undefined, + baseline: GeneratedFileBaseline | undefined, +): 'created' | 'modified' { + if (existing?.action === 'created') return 'created'; + if (!baseline) return existing ? 'modified' : 'created'; + return baseline.status === 'missing' ? 'created' : 'modified'; +} + +function buildGeneratedFile( + filePath: string, + action: 'created' | 'modified', + parts: { fullContent?: string; edits?: FileEditOp[]; baseline?: GeneratedFileBaseline } | undefined, + index: number, +): GeneratedFile { + const fileName = basenameOf(filePath); + const ext = extnameOf(filePath); + return { + filePath, + fileName, + ext, + mimeType: getMimeTypeForExt(ext), + contentType: classifyFileExt(ext), + action, + fullContent: parts?.fullContent, + edits: parts?.edits, + baseline: parts?.baseline, + lastSeenIndex: index, + }; +} + +function normaliseEol(text: string): string { + return text.replace(/\r\n/g, '\n'); +} + +function joinEditText(edits: FileEditOp[], side: 'old' | 'new'): string { + return edits.map((op) => normaliseEol(op[side] ?? '')).join(SNIPPET_SEPARATOR); +} + +function countLogicalLines(text: string): number { + const normalized = normaliseEol(text); + if (!normalized) return 0; + const parts = normalized.split('\n'); + return normalized.endsWith('\n') ? Math.max(1, parts.length - 1) : parts.length; +} + +function diffLineStats(oldText: string, newText: string): FileLineStats { + const pieces = diffLines(normaliseEol(oldText), normaliseEol(newText)); + let added = 0; + let removed = 0; + for (const piece of pieces) { + const count = typeof piece.count === 'number' ? piece.count : countLogicalLines(piece.value); + if (piece.added) added += count; + if (piece.removed) removed += count; + } + return { added, removed }; +} + +export function computeLineStats(file: GeneratedFile): FileLineStats | null { + if (!supportsInlineDiff(file)) return null; + + if (file.edits?.length) { + return diffLineStats(joinEditText(file.edits, 'old'), joinEditText(file.edits, 'new')); + } + + if (file.fullContent == null) return null; + + if (file.baseline?.status === 'ok') { + return diffLineStats(file.baseline.content, file.fullContent); + } + + if (file.baseline?.status === 'missing') { + return { added: countLogicalLines(file.fullContent), removed: 0 }; + } + + if (file.baseline?.status === 'unavailable') { + return null; + } + + if (file.action === 'created') { + return { added: countLogicalLines(file.fullContent), removed: 0 }; + } + + return null; +} + +/** + * Walk the messages in `[triggerIndex, segmentEnd]` (inclusive) and + * collect the unique files written or edited by tool calls in that + * window. Deduplicates by `filePath`; if the file is touched by both + * a `Write` and a later `Edit`, the action is upgraded to `'modified'` + * but the diff content is kept from the last edit. + */ +export function extractGeneratedFiles( + messages: RawMessage[], + triggerIndex: number, + segmentEnd: number, + baselineGetter?: (filePath: string) => GeneratedFileBaseline | undefined, +): GeneratedFile[] { + const map = new Map(); + const start = Math.max(0, Math.min(triggerIndex + 1, messages.length)); + const end = Math.max(start - 1, Math.min(segmentEnd, messages.length - 1)); + + for (let i = start; i <= end; i += 1) { + const message = messages[i]; + if (!message || message.role !== 'assistant') continue; + const content = message.content; + if (!Array.isArray(content)) continue; + + for (const block of content as ContentBlock[]) { + if (block.type !== 'tool_use' && block.type !== 'toolCall') continue; + const name = typeof block.name === 'string' ? block.name : ''; + if (!name) continue; + const input = block.input ?? block.arguments; + const filePath = pickFilePath(input); + if (!filePath) continue; + + const isWrite = WRITE_TOOLS.has(name); + const isEdit = EDIT_TOOLS.has(name); + const looksLikeFile = isWrite || isEdit || /\.[a-z0-9]{1,8}$/i.test(basenameOf(filePath)); + if (!isWrite && !isEdit && !looksLikeFile) continue; + + const existing = map.get(filePath); + + if (isWrite) { + const newContent = pickWriteContent(input); + const baseline = baselineGetter?.(filePath); + const next = buildGeneratedFile( + filePath, + determineWriteAction(existing, baseline), + { fullContent: newContent, edits: undefined, baseline }, + i, + ); + map.set(filePath, next); + continue; + } + + if (isEdit) { + const newOps = pickEditOps(input); + const next = buildGeneratedFile( + filePath, + 'modified', + { + fullContent: existing?.fullContent, + edits: [...(existing?.edits ?? []), ...newOps], + baseline: existing?.baseline, + }, + i, + ); + map.set(filePath, next); + continue; + } + + // Unknown tool but its input mentions a file path with extension — + // surface as best-effort "modified" without diff content. + if (!existing) { + map.set(filePath, buildGeneratedFile(filePath, 'modified', undefined, i)); + } else { + existing.lastSeenIndex = i; + } + } + } + + return Array.from(map.values()).sort((a, b) => a.lastSeenIndex - b.lastSeenIndex); +} diff --git a/src/lib/monaco/loader.ts b/src/lib/monaco/loader.ts new file mode 100644 index 0000000..8f298c5 --- /dev/null +++ b/src/lib/monaco/loader.ts @@ -0,0 +1,95 @@ +/** + * Self-hosted Monaco Editor loader. + * + * `@monaco-editor/react` defaults to fetching Monaco from a CDN, which + * is unusable in clawx's offline Electron environment. We instead bundle + * the editor + its language workers locally via Vite's `?worker` import + * so each preview overlay can spin up Monaco without any network. + * + * Importing this module once is enough — `loader.config({ monaco })` + * registers the bundled Monaco instance globally. + */ +import * as monaco from 'monaco-editor'; +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +import { loader } from '@monaco-editor/react'; + +interface MonacoEnvironment { + getWorker(workerId: string, label: string): Worker; +} + +(self as unknown as { MonacoEnvironment: MonacoEnvironment }).MonacoEnvironment = { + getWorker(_workerId: string, label: string): Worker { + if (label === 'json') return new jsonWorker(); + if (label === 'typescript' || label === 'javascript') return new tsWorker(); + if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker(); + if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker(); + return new editorWorker(); + }, +}; + +loader.config({ monaco }); + +export { monaco }; +export { Editor, DiffEditor, loader } from '@monaco-editor/react'; + +const EXT_LANGUAGE_MAP: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.md': 'markdown', + '.markdown': 'markdown', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'plaintext', + '.xml': 'xml', + '.py': 'python', + '.rb': 'ruby', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.kt': 'kotlin', + '.swift': 'swift', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.ps1': 'powershell', + '.sql': 'sql', + '.dart': 'dart', + '.php': 'php', + '.c': 'c', + '.cc': 'cpp', + '.cpp': 'cpp', + '.h': 'cpp', + '.hpp': 'cpp', + '.cs': 'csharp', + '.vue': 'html', + '.dockerfile': 'dockerfile', +}; + +export function languageForExt(ext: string): string { + if (!ext) return 'plaintext'; + const lower = ext.toLowerCase(); + return EXT_LANGUAGE_MAP[lower] ?? 'plaintext'; +} + +export function languageForPath(path: string): string { + if (!path) return 'plaintext'; + const norm = path.replace(/\\/g, '/').toLowerCase(); + if (norm.endsWith('/dockerfile') || norm === 'dockerfile') return 'dockerfile'; + const dot = norm.lastIndexOf('.'); + if (dot < 0) return 'plaintext'; + return languageForExt(norm.slice(dot)); +} diff --git a/src/lib/skill-files.ts b/src/lib/skill-files.ts new file mode 100644 index 0000000..8a30a4c --- /dev/null +++ b/src/lib/skill-files.ts @@ -0,0 +1,229 @@ +/** + * Skill directory introspection. + * + * Recursively scans a skill's `baseDir`, filtering noisy directories + * (node_modules, __pycache__, .venv, …) and assigns each surviving file + * to one of four buckets so the Skills detail page can render + * "Docs / Scripts / Hooks / Assets" sections. + */ +import { listDir } from './api-client'; +import { + basenameOf, + classifyFileExt, + extnameOf, + getMimeTypeForExt, + type FileContentType, +} from './generated-files'; + +export type SkillFileCategory = 'doc' | 'script' | 'hook' | 'asset' | 'other'; + +export interface SkillFile { + filePath: string; + relativePath: string; + fileName: string; + ext: string; + mimeType: string; + contentType: FileContentType; + category: SkillFileCategory; + size: number; +} + +export interface SkillFileGroups { + docs: SkillFile[]; + scripts: SkillFile[]; + hooks: SkillFile[]; + assets: SkillFile[]; + others: SkillFile[]; +} + +export const EMPTY_SKILL_GROUPS: SkillFileGroups = { + docs: [], + scripts: [], + hooks: [], + assets: [], + others: [], +}; + +const SCAN_BLACKLIST = new Set([ + 'node_modules', + '.venv', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', +]); + +const SCRIPT_EXTS = new Set([ + '.py', '.js', '.ts', '.mjs', '.cjs', '.sh', '.bash', '.zsh', '.ps1', + '.rb', '.go', '.rs', '.java', '.kt', '.swift', + '.tsx', '.jsx', '.lua', '.r', +]); + +const ASSET_EXTS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', + '.mp3', '.wav', '.ogg', '.flac', '.m4a', + '.mp4', '.webm', '.mov', + '.ttf', '.otf', '.woff', '.woff2', + '.pdf', '.zip', '.tar', '.gz', +]); + +const DOC_NAME_HINTS = ['readme', 'changelog', 'license', 'contributing', 'authors']; + +const ASSET_PATH_HINTS = ['/assets/', '/references/', '/templates/', '/static/', '/public/']; + +const MAX_SCAN_DEPTH = 3; +const MAX_SCAN_FILES = 400; + +function categorizeSkillFile(relativePath: string, fileName: string, ext: string): SkillFileCategory { + const lowerExt = ext.toLowerCase(); + const lowerName = fileName.toLowerCase(); + const lowerRel = relativePath.replace(/\\/g, '/').toLowerCase(); + const lowerRelWithSlash = `/${lowerRel}`; + + if (lowerRel.startsWith('hooks/') || lowerRelWithSlash.includes('/hooks/')) { + return 'hook'; + } + + if (lowerRel.startsWith('scripts/') || lowerRelWithSlash.includes('/scripts/')) { + return 'script'; + } + + if (ASSET_PATH_HINTS.some((hint) => lowerRelWithSlash.includes(hint))) { + return 'asset'; + } + + if (lowerExt === '.md' || lowerExt === '.markdown' || lowerExt === '.rst' || lowerExt === '.txt') { + return 'doc'; + } + + if (DOC_NAME_HINTS.some((hint) => lowerName.startsWith(hint))) { + return 'doc'; + } + + if (SCRIPT_EXTS.has(lowerExt)) { + return 'script'; + } + + if (ASSET_EXTS.has(lowerExt)) { + return 'asset'; + } + + return 'other'; +} + +function buildSkillFile(absPath: string, relPath: string, size: number): SkillFile { + const fileName = basenameOf(absPath); + const ext = extnameOf(absPath); + return { + filePath: absPath, + relativePath: relPath, + fileName, + ext, + mimeType: getMimeTypeForExt(ext), + contentType: classifyFileExt(ext), + category: categorizeSkillFile(relPath, fileName, ext), + size, + }; +} + +function relPathFromBase(absPath: string, baseDir: string): string { + const baseNorm = baseDir.replace(/\\/g, '/').replace(/\/+$/, ''); + const norm = absPath.replace(/\\/g, '/'); + if (norm === baseNorm) return ''; + if (norm.startsWith(baseNorm + '/')) { + return norm.slice(baseNorm.length + 1); + } + return norm; +} + +/** + * Scan `baseDir` (skill root) and return files grouped by category. + * Stops descending into noise directories and caps total file count. + */ +export async function loadSkillFiles(baseDir: string): Promise { + if (!baseDir) return EMPTY_SKILL_GROUPS; + + const groups: SkillFileGroups = { + docs: [], + scripts: [], + hooks: [], + assets: [], + others: [], + }; + + const files: SkillFile[] = []; + + const walk = async (dir: string, depth: number): Promise => { + if (depth > MAX_SCAN_DEPTH) return; + if (files.length >= MAX_SCAN_FILES) return; + const result = await listDir(dir); + if (!result.ok || !result.entries) return; + + for (const entry of result.entries) { + if (files.length >= MAX_SCAN_FILES) break; + if (entry.isDir) { + if (SCAN_BLACKLIST.has(entry.name)) continue; + if (entry.name.startsWith('.')) continue; + await walk(entry.path, depth + 1); + } else { + if (entry.name.startsWith('.')) continue; + const rel = relPathFromBase(entry.path, baseDir); + files.push(buildSkillFile(entry.path, rel, entry.size)); + } + } + }; + + try { + await walk(baseDir, 1); + } catch { + return groups; + } + + for (const file of files) { + switch (file.category) { + case 'doc': + groups.docs.push(file); + break; + case 'script': + groups.scripts.push(file); + break; + case 'hook': + groups.hooks.push(file); + break; + case 'asset': + groups.assets.push(file); + break; + default: + groups.others.push(file); + } + } + + // Stable order: SKILL.md first in docs, then alphabetical; others alphabetical. + const skillMdSort = (a: SkillFile, b: SkillFile): number => { + const aIsSkill = a.fileName.toLowerCase() === 'skill.md'; + const bIsSkill = b.fileName.toLowerCase() === 'skill.md'; + if (aIsSkill !== bIsSkill) return aIsSkill ? -1 : 1; + return a.relativePath.localeCompare(b.relativePath); + }; + const alphaSort = (a: SkillFile, b: SkillFile): number => + a.relativePath.localeCompare(b.relativePath); + + groups.docs.sort(skillMdSort); + groups.scripts.sort(alphaSort); + groups.hooks.sort(alphaSort); + groups.assets.sort(alphaSort); + groups.others.sort(alphaSort); + + return groups; +} + +export function isSkillFileGroupsEmpty(groups: SkillFileGroups): boolean { + return groups.docs.length === 0 && + groups.scripts.length === 0 && + groups.hooks.length === 0 && + groups.assets.length === 0 && + groups.others.length === 0; +} diff --git a/src/lib/workspace-tree.ts b/src/lib/workspace-tree.ts new file mode 100644 index 0000000..2e62e1e --- /dev/null +++ b/src/lib/workspace-tree.ts @@ -0,0 +1,124 @@ +/** + * Agent workspace file tree. + * + * Loads the directory tree rooted at `agent.workspace` (e.g. + * `~/.openclaw/workspace` for the default agent) for the + * `WorkspaceBrowserBody` / artifact-panel browser tab. Strictly scoped to that one directory + * so sibling configuration paths (`runs/`, `agents/`, + * `auth-profiles.json`, …) under `~/.openclaw` are never exposed. + */ +import { listTree, type TreeNode } from './api-client'; +import { + basenameOf, + classifyFileExt, + extnameOf, + getMimeTypeForExt, + type FileContentType, +} from './generated-files'; + +export interface WorkspaceTreeNode { + name: string; + relPath: string; + absPath: string; + isDir: boolean; + size?: number; + mtime?: number; + /** True when the file's mtime is at or after the provided run start time. */ + isFresh?: boolean; + ext?: string; + mimeType?: string; + contentType?: FileContentType; + children?: WorkspaceTreeNode[]; +} + +export interface LoadWorkspaceTreeResult { + root: WorkspaceTreeNode; + truncated: boolean; +} + +export interface LoadWorkspaceTreeOptions { + runStartedAt?: number | null; + maxDepth?: number; + maxNodes?: number; + includeHidden?: boolean; +} + +function decorate(node: TreeNode, runStartedAt: number | null): WorkspaceTreeNode { + if (node.isDir) { + const children = (node.children ?? []).map((child) => decorate(child, runStartedAt)); + return { + name: node.name, + relPath: node.relPath, + absPath: node.absPath, + isDir: true, + mtime: node.mtime, + children, + }; + } + const ext = extnameOf(node.absPath); + const isFresh = + runStartedAt != null && typeof node.mtime === 'number' && node.mtime >= runStartedAt; + return { + name: node.name, + relPath: node.relPath, + absPath: node.absPath, + isDir: false, + size: node.size, + mtime: node.mtime, + ext, + mimeType: getMimeTypeForExt(ext), + contentType: classifyFileExt(ext), + isFresh, + }; +} + +export async function loadWorkspaceTree( + workspace: string, + opts: LoadWorkspaceTreeOptions = {}, +): Promise { + if (!workspace) return null; + const result = await listTree(workspace, { + maxDepth: opts.maxDepth, + maxNodes: opts.maxNodes, + includeHidden: opts.includeHidden, + }); + if (!result.ok || !result.root) { + return null; + } + const runStartedAt = opts.runStartedAt ?? null; + const root = decorate(result.root, runStartedAt); + // Use the workspace's directory name for the synthetic root label so the + // tree shows e.g. `workspace` instead of an absolute path. + root.name = basenameOf(workspace) || root.name; + return { root, truncated: !!result.truncated }; +} + +export function findNode(root: WorkspaceTreeNode, relPath: string): WorkspaceTreeNode | null { + if (!root) return null; + if (root.relPath === relPath) return root; + if (!root.children) return null; + for (const child of root.children) { + const hit = findNode(child, relPath); + if (hit) return hit; + } + return null; +} + +export function collectInitialExpanded( + root: WorkspaceTreeNode | null, + initialDepth = 1, +): Set { + const out = new Set(); + if (!root) return out; + const walk = (node: WorkspaceTreeNode, depth: number): void => { + if (!node.isDir) return; + if (depth <= initialDepth) { + out.add(node.relPath); + } + for (const child of node.children ?? []) { + walk(child, depth + 1); + } + }; + walk(root, 0); + return out; +} diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 2c4ce7b..7f5de17 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -39,6 +39,12 @@ interface ChatMessageProps { durationMs?: number; summary?: string; }>; + /** + * Optional callback invoked when a non-image file card is clicked. + * When provided, the file opens in the in-app preview panel instead of + * the system default editor. + */ + onOpenFile?: (file: AttachedFileMeta) => void; } interface ExtractedImage { url?: string; data?: string; mimeType: string; } @@ -88,6 +94,7 @@ export const ChatMessage = memo(function ChatMessage({ suppressAssistantText = false, isStreaming = false, streamingTools = [], + onOpenFile, }: ChatMessageProps) { const isUser = message.role === 'user'; const role = typeof message.role === 'string' ? message.role.toLowerCase() : ''; @@ -102,12 +109,14 @@ export const ChatMessage = memo(function ChatMessage({ const images = extractImages(message); const tools = extractToolUse(message); const visibleTools = suppressToolCards ? [] : tools; - const shouldHideProcessAttachments = suppressProcessAttachments - && (hasText || images.length > 0 || visibleTools.length > 0); - - const attachedFiles = shouldHideProcessAttachments - ? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result') - : (message._attachedFiles || []); + const rawAttachedFiles = message._attachedFiles || []; + const filteredProcessAttachments = rawAttachedFiles.filter((file) => file.source !== 'tool-result' && file.source !== 'message-ref'); + // When a message is attachment-only, keep those attachments visible even if + // process attachments are generally suppressed for this run segment — + // otherwise the reply disappears entirely. + const attachedFiles = suppressProcessAttachments && (hasText || images.length > 0 || visibleTools.length > 0) + ? filteredProcessAttachments + : rawAttachedFiles; const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null); // Never render tool result messages in chat UI @@ -198,7 +207,7 @@ export const ChatMessage = memo(function ChatMessage({ ); } // Non-image files → file card - return ; + return ; })} )} @@ -257,7 +266,7 @@ export const ChatMessage = memo(function ChatMessage({ ); } - return ; + return ; })} )} @@ -454,12 +463,15 @@ function FileIcon({ mimeType, className }: { mimeType: string; className?: strin return ; } -function FileCard({ file }: { file: AttachedFileMeta }) { +function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) { const handleOpen = useCallback(() => { - if (file.filePath) { + if (!file.filePath) return; + if (onOpen) { + onOpen(file); + } else { invokeIpc('shell:openPath', file.filePath); } - }, [file.filePath]); + }, [file, onOpen]); return (
    s.refresh); const loading = useChatStore((s) => s.loading); const currentAgentId = useChatStore((s) => s.currentAgentId); const agents = useAgentsStore((s) => s.agents); + const openBrowser = useArtifactPanel((s) => s.openBrowser); + const panelOpen = useArtifactPanel((s) => s.open); + const panelTab = useArtifactPanel((s) => s.tab); + const closePanel = useArtifactPanel((s) => s.close); const { t } = useTranslation('chat'); - const currentAgentName = useMemo( - () => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId, + const currentAgent = useMemo( + () => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null, [agents, currentAgentId], ); + const currentAgentName = currentAgent?.name ?? currentAgentId; + + const browserActive = WORKSPACE_BROWSER_ENABLED && panelOpen && panelTab === 'browser'; return (
    @@ -29,6 +38,25 @@ export function ChatToolbar() { {t('toolbar.currentAgent', { agent: currentAgentName })}
    + {WORKSPACE_BROWSER_ENABLED && ( + + + + + +

    {t('toolbar.workspace', '工作空间')}

    +
    +
    + )} {/* Refresh */} @@ -38,6 +66,7 @@ export function ChatToolbar() { className="h-8 w-8" onClick={() => refresh()} disabled={loading} + aria-label={t('toolbar.refresh')} > diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 27e32df..ee02b44 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -4,11 +4,13 @@ * via gateway:rpc IPC. Session selector, thinking toggle, and refresh * are in the toolbar; messages render with markdown + streaming. */ -import { useEffect, useMemo, useState } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Loader2, Sparkles } from 'lucide-react'; import { useChatStore, type RawMessage } from '@/stores/chat'; +import { buildBaselineRunKey, getBaseline } from '@/stores/baseline-cache'; import { useGatewayStore } from '@/stores/gateway'; import { useAgentsStore } from '@/stores/agents'; +import { useArtifactPanel } from '@/stores/artifact-panel'; import { hostApiFetch } from '@/lib/host-api'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { ChatMessage } from './ChatMessage'; @@ -21,6 +23,18 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant'; import { useMinLoading } from '@/hooks/use-min-loading'; +import { extractGeneratedFiles, generatedFileHasDiffPayload, type GeneratedFile } from '@/lib/generated-files'; +import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel'; +import type { FilePreviewTarget } from '@/components/file-preview/types'; +import { buildPreviewTarget } from '@/components/file-preview/build-preview-target'; +import type { AttachedFileMeta } from '@/stores/chat/types'; + +const ArtifactPanelLazy = lazy(() => + import('@/components/file-preview/ArtifactPanel').then((m) => ({ default: m.ArtifactPanel })), +); +const PanelResizeDividerLazy = lazy(() => + import('@/components/file-preview/PanelResizeDivider').then((m) => ({ default: m.PanelResizeDivider })), +); type GraphStepCacheEntry = { steps: ReturnType; @@ -61,6 +75,20 @@ function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] { .map((step) => step.detail!); } +function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget { + return { + filePath: file.filePath, + fileName: file.fileName, + ext: file.ext, + mimeType: file.mimeType, + contentType: file.contentType, + action: file.action, + fullContent: file.fullContent, + baseline: file.baseline, + edits: file.edits, + }; +} + // Keep the last non-empty execution-graph snapshot per session/run outside // React state so `loadHistory` refreshes can still fall back to the previous // steps without tripping React's set-state-in-effect lint rule. @@ -91,7 +119,32 @@ export function Chat() { const agents = useAgentsStore((s) => s.agents); const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession); + const lastUserMessageAt = useChatStore((s) => s.lastUserMessageAt); + const agentsList = useAgentsStore((s) => s.agents); + const currentAgent = useMemo( + () => (agentsList ?? []).find((a) => a.id === currentAgentId) ?? null, + [agentsList, currentAgentId], + ); + const panelOpen = useArtifactPanel((s) => s.open); + const panelWidthPct = useArtifactPanel((s) => s.widthPct); + const openChanges = useArtifactPanel((s) => s.openChanges); + const openPreview = useArtifactPanel((s) => s.openPreview); + const closeArtifactPanel = useArtifactPanel((s) => s.close); + const splitContainerRef = useRef(null); + // Close the panel when the session changes — its contents would otherwise + // be stale (file list belongs to the previous chat). + useEffect(() => { + closeArtifactPanel(); + }, [currentSessionKey, closeArtifactPanel]); const [childTranscripts, setChildTranscripts] = useState>({}); + + // Callback for file cards in chat messages — opens the in-app preview + // panel instead of the system default editor. + const handleOpenAttachedFile = useCallback((file: AttachedFileMeta) => { + if (!file.filePath) return; + const target = buildPreviewTarget(file.filePath, file.fileName); + openPreview(target); + }, [openPreview]); // Persistent per-run override for the Execution Graph's expanded/collapsed // state. Keyed by a stable run id (trigger message id, or a fallback of // `${sessionKey}:${triggerIdx}`) so user toggles survive the `loadHistory` @@ -505,6 +558,38 @@ export function Chat() { // streaming or has a reply override) during render instead of in an effect, // so we don't violate react-hooks/set-state-in-effect. Explicit user toggles // still win via `graphExpandedOverrides` and are merged in at the call site. + // Pre-compute generated files per run (memoised so the cards and the + // ArtifactPanel can both read them without re-parsing tool calls every + // render). + const filesByRun = useMemo(() => { + const map = new Map(); + for (const card of userRunCards) { + const userTurnOrdinal = messages + .slice(0, card.triggerIndex + 1) + .filter((msg) => msg.role === 'user' && (!Array.isArray(msg.content) || !(msg.content as Array<{ type?: string }>).every((b) => b.type === 'tool_result' || b.type === 'toolResult'))) + .length; + const runKey = buildBaselineRunKey(currentSessionKey, userTurnOrdinal); + const raw = extractGeneratedFiles( + messages, + card.triggerIndex, + card.segmentEnd, + runKey ? (filePath) => getBaseline(runKey, filePath) : undefined, + ); + map.set(card.triggerIndex, raw.filter(generatedFileHasDiffPayload)); + } + return map; + }, [currentSessionKey, userRunCards, messages]); + const allGeneratedFiles = useMemo(() => { + const all: GeneratedFile[] = []; + for (const files of filesByRun.values()) all.push(...files); + return all; + }, [filesByRun]); + + const refreshSignal = useMemo(() => { + if (sending) return undefined; + return lastUserMessageAt ?? 0; + }, [sending, lastUserMessageAt]); + const autoCollapsedRunKeys = useMemo(() => { const keys = new Set(); for (const card of userRunCards) { @@ -573,7 +658,13 @@ export function Chat() { }, [userRunCards, messages, currentSessionKey]); return ( -
    +
    + {/* Left column: chat */} +
    {/* Toolbar */}
    @@ -611,6 +702,7 @@ export function Chat() { textOverride={replyTextOverrides.get(idx)} suppressToolCards={suppressToolCards} suppressProcessAttachments={suppressToolCards} + onOpenFile={handleOpenAttachedFile} /> {userRunCards .filter((card) => card.triggerIndex === idx) @@ -629,18 +721,27 @@ export function Chat() { const expanded = userOverride != null ? userOverride : !autoCollapsedRunKeys.has(runKey); + const generatedFiles = filesByRun.get(card.triggerIndex) ?? []; return ( - - setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next })) - } - /> +
    + + setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next })) + } + /> + {generatedFiles.length > 0 && ( + openChanges(generatedFileToTarget(file))} + /> + )} +
    ); })}
    @@ -681,6 +782,7 @@ export function Chat() { textOverride={streamingReplyText ?? undefined} isStreaming streamingTools={streamingReplyText != null ? [] : streamingTools} + onOpenFile={handleOpenAttachedFile} /> )} @@ -742,6 +844,35 @@ export function Chat() { sending={sending || hasActiveExecutionGraph} isEmpty={isEmpty} /> +
    + + {/* Right column: artifact / file preview panel (WorkBuddy-style) */} + {panelOpen && ( + <> + + + +
    + } + > + + + + + )} {/* Transparent loading overlay */} {minLoading && !sending && ( diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index d545013..621a79b 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -2,7 +2,7 @@ * Skills Page * Browse and manage AI skills */ -import { useEffect, useState, useCallback } from 'react'; +import { Suspense, lazy, useEffect, useState, useCallback } from 'react'; import { Search, Puzzle, @@ -10,13 +10,9 @@ import { Package, X, AlertCircle, - Plus, - Key, Trash2, RefreshCw, FolderOpen, - FileCode, - Globe, Copy, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -36,6 +32,23 @@ import type { Skill } from '@/types/skill'; import { rendererExtensionRegistry } from '@/extensions/registry'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; +import { SkillFileSections } from '@/components/file-preview/SkillFileSections'; +import type { FilePreviewTarget } from '@/components/file-preview/FilePreviewOverlay'; +import type { SkillFile } from '@/lib/skill-files'; + +const FilePreviewOverlayLazy = lazy(() => + import('@/components/file-preview/FilePreviewOverlay').then((m) => ({ default: m.FilePreviewOverlay })), +); + +function skillFileToTarget(file: SkillFile): FilePreviewTarget { + return { + filePath: file.filePath, + fileName: file.fileName, + ext: file.ext, + mimeType: file.mimeType, + contentType: file.contentType, + }; +} const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']); const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']); @@ -70,57 +83,9 @@ function resolveSkillSourceLabel(skill: Skill, t: TFunction<'skills'>): string { function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOpenFolder }: SkillDetailDialogProps) { const { t } = useTranslation('skills'); - const { fetchSkills } = useSkillsStore(); - const [envVars, setEnvVars] = useState>([]); - const [apiKey, setApiKey] = useState(''); - const [isSaving, setIsSaving] = useState(false); + const [openedSkillFile, setOpenedSkillFile] = useState(null); const detailMetaComponents = rendererExtensionRegistry.getSkillDetailMetaComponents(); - // Initialize config from skill - useEffect(() => { - if (!skill) return; - - // API Key - if (skill.config?.apiKey) { - setApiKey(String(skill.config.apiKey)); - } else { - setApiKey(''); - } - - // Env Vars - if (skill.config?.env) { - const vars = Object.entries(skill.config.env).map(([key, value]) => ({ - key, - value: String(value), - })); - setEnvVars(vars); - } else { - setEnvVars([]); - } - }, [skill]); - - const handleOpenClawhub = async () => { - if (!skill?.slug) return; - await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`); - }; - - const handleOpenEditor = async () => { - if (!skill?.id) return; - try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', { - method: 'POST', - body: JSON.stringify({ skillKey: skill.id, slug: skill.slug, baseDir: skill.baseDir }), - }); - if (result.success) { - toast.success(t('toast.openedEditor')); - } else { - toast.error(result.error || t('toast.failedEditor')); - } - } catch (err) { - toast.error(t('toast.failedEditor') + ': ' + String(err)); - } - }; - const handleCopyPath = async () => { if (!skill?.baseDir) return; try { @@ -131,65 +96,17 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp } }; - const handleAddEnv = () => { - setEnvVars([...envVars, { key: '', value: '' }]); - }; - - const handleUpdateEnv = (index: number, field: 'key' | 'value', value: string) => { - const newVars = [...envVars]; - newVars[index] = { ...newVars[index], [field]: value }; - setEnvVars(newVars); - }; - - const handleRemoveEnv = (index: number) => { - const newVars = [...envVars]; - newVars.splice(index, 1); - setEnvVars(newVars); - }; - - const handleSaveConfig = async () => { - if (isSaving || !skill) return; - setIsSaving(true); - try { - // Build env object, filtering out empty keys - const envObj = envVars.reduce((acc, curr) => { - const key = curr.key.trim(); - const value = curr.value.trim(); - if (key) { - acc[key] = value; - } - return acc; - }, {} as Record); - - // Use direct file access instead of Gateway RPC for reliability - const result = await invokeIpc<{ success: boolean; error?: string }>( - 'skill:updateConfig', - { - skillKey: skill.id, - apiKey: apiKey || '', // Empty string will delete the key - env: envObj // Empty object will clear all env vars - } - ) as { success: boolean; error?: string }; - - if (!result.success) { - throw new Error(result.error || 'Unknown error'); - } - - // Refresh skills from gateway to get updated config - await fetchSkills(); - - toast.success(t('detail.configSaved')); - } catch (err) { - toast.error(t('toast.failedSave') + ': ' + String(err)); - } finally { - setIsSaving(false); - } - }; - if (!skill) return null; return ( !open && onClose()}> + + setOpenedSkillFile(null)} + /> +
    - {/* API Key Section */} - {!skill.isCore && ( -
    -

    - - {t('detail.apiKey')} -

    - setApiKey(e.target.value)} - type="password" - className="h-[44px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40" - /> -

    - {t('detail.apiKeyDesc', 'The primary API key for this skill. Leave blank if not required or configured elsewhere.')} -

    -
    - )} - - {/* Environment Variables Section */} - {!skill.isCore && ( + {/* File Sections — read-only preview of skill content */} + {skill.baseDir && (
    -
    -
    -

    - {t('detail.envVars')} - {envVars.length > 0 && ( - - {envVars.length} - - )} -

    -
    - -
    - -
    - {envVars.length === 0 && ( -
    - {t('detail.noEnvVars', 'No environment variables configured.')} -
    - )} - - {envVars.map((env, index) => ( -
    - handleUpdateEnv(index, 'key', e.target.value)} - className="flex-1 h-[40px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground" - placeholder={t('detail.keyPlaceholder', 'Key')} - /> - handleUpdateEnv(index, 'value', e.target.value)} - className="flex-1 h-[40px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground" - placeholder={t('detail.valuePlaceholder', 'Value')} - /> - -
    - ))} -
    +

    + {t('detail.sections.title', { defaultValue: '内容' })} +

    + setOpenedSkillFile(skillFileToTarget(file))} + />
    )} - {/* External Links */} - {skill.slug && !skill.isBundled && !skill.isCore && ( -
    - - -
    - )}
    - {/* Centered Footer Buttons */} -
    - {!skill.isCore && ( - - )} - - {!skill.isCore && ( + {/* Centered Footer Button — uninstall / disable / enable */} + {!skill.isCore && ( +
    - )} -
    +
    + )} diff --git a/src/stores/artifact-panel.ts b/src/stores/artifact-panel.ts new file mode 100644 index 0000000..5c5fa9a --- /dev/null +++ b/src/stores/artifact-panel.ts @@ -0,0 +1,91 @@ +/** + * Artifact panel state. + * + * Drives the right-side split panel on the Chat page: which tab is + * active (变更 / 预览 / 工作空间), the focused file shared across the + * 变更 and 预览 tabs, and the open/close state. + * + * The actual content (file lists, workspace tree, etc.) is provided by + * the chat page as props — we only track UI state here so the panel can + * be opened/closed/focused from anywhere (file cards, toolbar buttons, + * "查看文件变更 →" links, …). + * + * `widthPct` is persisted via `zustand/middleware`'s `persist` so the + * user's preferred split survives reloads. + */ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { FilePreviewTarget } from '@/components/file-preview/types'; + +export type ArtifactTab = 'changes' | 'preview' | 'browser'; + +/** Width clamp (% of the chat container). */ +export const ARTIFACT_PANEL_MIN_WIDTH = 28; +export const ARTIFACT_PANEL_MAX_WIDTH = 70; +export const ARTIFACT_PANEL_DEFAULT_WIDTH = 45; + +interface ArtifactPanelState { + open: boolean; + tab: ArtifactTab; + /** + * The currently selected file inside the panel. Shared between the + * 变更 tab (drives the right-pane diff) and the 预览 tab (drives the + * rendered preview). `null` means "no selection" — the changes tab + * may auto-select the first file in that case. + */ + focusedFile: FilePreviewTarget | null; + /** Persisted panel width as a % of the chat container (clamped on read). */ + widthPct: number; + setTab: (tab: ArtifactTab) => void; + setFocusedFile: (file: FilePreviewTarget | null) => void; + /** Open the changes tab. Optionally focus a single file. */ + openChanges: (file?: FilePreviewTarget | null) => void; + /** Open the preview tab on a specific file. */ + openPreview: (file?: FilePreviewTarget | null) => void; + /** Open the workspace browser tab. */ + openBrowser: () => void; + toggle: () => void; + close: () => void; + /** Update the panel width (clamped). */ + setWidthPct: (pct: number) => void; +} + +function clampWidth(pct: number): number { + if (!Number.isFinite(pct)) return ARTIFACT_PANEL_DEFAULT_WIDTH; + if (pct < ARTIFACT_PANEL_MIN_WIDTH) return ARTIFACT_PANEL_MIN_WIDTH; + if (pct > ARTIFACT_PANEL_MAX_WIDTH) return ARTIFACT_PANEL_MAX_WIDTH; + return pct; +} + +export const useArtifactPanel = create()( + persist( + (set, get) => ({ + open: false, + tab: 'changes', + focusedFile: null, + widthPct: ARTIFACT_PANEL_DEFAULT_WIDTH, + setTab: (tab) => { + // The browser tab has its own internal selection (workspace tree) + // and should not inherit a chat-side focused file. The changes + // and preview tabs share `focusedFile`. + if (tab === 'browser') { + set({ tab, focusedFile: null }); + } else { + set({ tab, focusedFile: get().focusedFile }); + } + }, + setFocusedFile: (focusedFile) => set({ focusedFile }), + openChanges: (file = null) => set({ open: true, tab: 'changes', focusedFile: file ?? null }), + openPreview: (file = null) => set({ open: true, tab: 'preview', focusedFile: file ?? null }), + openBrowser: () => set({ open: true, tab: 'browser', focusedFile: null }), + toggle: () => set((s) => ({ open: !s.open })), + close: () => set({ open: false, focusedFile: null }), + setWidthPct: (pct) => set({ widthPct: clampWidth(pct) }), + }), + { + name: 'clawx.artifact-panel', + // Only persist the user-controlled width — open/tab/focus reset on reload. + partialize: (state) => ({ widthPct: state.widthPct }), + }, + ), +); diff --git a/src/stores/baseline-cache.ts b/src/stores/baseline-cache.ts new file mode 100644 index 0000000..b40cb50 --- /dev/null +++ b/src/stores/baseline-cache.ts @@ -0,0 +1,98 @@ +/** + * Per-run baseline content cache for file diff computation. + * + * When the AI issues a Write-family tool_use, we read the target file from + * disk *before* the runtime actually writes it, so we have the "before" + * content to render a proper before/after diff. Cache entries are scoped to a + * single run (represented by a stable run key derived from the triggering user + * message), not shared across the whole session — otherwise a later run that + * edits the same path would incorrectly diff against an older baseline. + */ +import { readTextFile, type FilePreviewError } from '@/lib/api-client'; +import type { GeneratedFileBaseline } from '@/lib/generated-files'; + +const KEY_SEPARATOR = '\u0000'; +const cache = new Map(); +const inflight = new Map>(); + +function makeCompositeKey(runKey: string, filePath: string): string { + return `${runKey}${KEY_SEPARATOR}${filePath}`; +} + +/** + * Build a stable per-run key from the session + user-turn ordinal. + * + * We intentionally avoid Gateway `runId` (not present after history reload) + * and timestamps (the optimistic local user message timestamp can differ from + * the persisted history timestamp). The nth real user turn inside a session is + * stable across streaming and post-final history reloads, which makes it a + * reliable join key for captured baselines. + */ +export function buildBaselineRunKey(sessionKey: string, userTurnOrdinal: number): string | null { + const normalizedSessionKey = sessionKey.trim(); + if (!normalizedSessionKey) return null; + if (!Number.isFinite(userTurnOrdinal) || userTurnOrdinal <= 0) return null; + return `${normalizedSessionKey}|turn:${userTurnOrdinal}`; +} + +export function getBaseline(runKey: string, filePath: string): GeneratedFileBaseline | undefined { + return cache.get(makeCompositeKey(runKey, filePath)); +} + +export function hasBaseline(runKey: string, filePath: string): boolean { + return cache.has(makeCompositeKey(runKey, filePath)); +} + +function unavailable(reason: FilePreviewError | string | undefined): GeneratedFileBaseline { + return { status: 'unavailable', reason: String(reason ?? 'unknown') }; +} + +/** + * Capture the baseline for `filePath` inside `runKey` if we haven't already. + * Returns immediately — the IPC read runs in the background. + */ +export function captureBaseline(runKey: string, filePath: string): void { + if (!runKey || !filePath) return; + + const key = makeCompositeKey(runKey, filePath); + if (cache.has(key) || inflight.has(key)) return; + + const promise = (async () => { + try { + const result = await readTextFile(filePath); + if (result.ok && typeof result.content === 'string') { + cache.set(key, { status: 'ok', content: result.content }); + } else if (result.error === 'notFound') { + cache.set(key, { status: 'missing' }); + } else { + cache.set(key, unavailable(result.error)); + } + } catch (error) { + cache.set(key, unavailable(error instanceof Error ? error.message : String(error))); + } finally { + inflight.delete(key); + } + })(); + + inflight.set(key, promise); +} + +export function clearBaselinesForRun(runKey: string): void { + if (!runKey) return; + for (const key of cache.keys()) { + if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) { + cache.delete(key); + } + } + for (const key of inflight.keys()) { + if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) { + inflight.delete(key); + } + } +} + +/** Clear all cached baselines (call on session switch). */ +export function clearBaselines(): void { + cache.clear(); + inflight.clear(); +} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 2411ba2..d431e27 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -7,6 +7,7 @@ import { create } from 'zustand'; import { hostApiFetch } from '@/lib/host-api'; import { useGatewayStore } from './gateway'; import { useAgentsStore } from './agents'; +import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache'; import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils'; import { CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, @@ -620,6 +621,9 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] { } } } + // Tag all files from tool results so ChatMessage can suppress them + // in segments that already have an ExecutionGraphCard. + for (const f of imageFiles) f.source = 'tool-result'; pending.push(...imageFiles); // 2. [media attached: ...] patterns in tool result text output @@ -628,12 +632,12 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] { const mediaRefs = extractMediaRefs(text); const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); for (const ref of mediaRefs) { - pending.push(makeAttachedFile(ref)); + pending.push({ ...makeAttachedFile(ref), source: 'tool-result' }); } // 3. Raw file paths in tool result text (documents, audio, video, etc.) for (const ref of extractRawFilePaths(text)) { if (!mediaRefPaths.has(ref.filePath)) { - pending.push(makeAttachedFile(ref)); + pending.push({ ...makeAttachedFile(ref), source: 'tool-result' }); } } } @@ -710,9 +714,9 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { const files: AttachedFileMeta[] = allRefs.map(ref => { const cached = _imageCache.get(ref.filePath); - if (cached) return { ...cached, filePath: ref.filePath }; + if (cached) return { ...cached, filePath: ref.filePath, source: 'message-ref' as const }; const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; - return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath }; + return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source: 'message-ref' as const }; }); return { ...msg, _attachedFiles: files }; }); @@ -1023,6 +1027,71 @@ function isRuntimeSystemInjection(text: string): boolean { return false; } +// ── Write tool_use baseline capture ───────────────────────────── +// +// Tool name sets mirror generated-files.ts so we detect the same tools. +const BASELINE_WRITE_TOOLS = new Set([ + 'Write', 'write_file', 'create_file', 'WriteFile', 'createFile', 'write', +]); +const BASELINE_EDIT_TOOLS = new Set([ + 'Edit', 'edit', 'edit_file', 'EditFile', + 'StrReplace', 'str_replace', 'str_replace_editor', + 'MultiEdit', 'multi_edit', 'multiEdit', +]); +const BASELINE_FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path']; + +function pickFilePathFromInput(input: unknown): string | null { + if (!input || typeof input !== 'object') return null; + const rec = input as Record; + for (const key of BASELINE_FILE_PATH_KEYS) { + const value = rec[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +/** + * Scan a streaming message for Write/Edit tool_use blocks and trigger + * async baseline reads from disk for each target file. Called on every + * `delta` event; `captureBaseline` is idempotent — duplicate calls for + * the same path are no-ops. + */ +function isBaselineRealUserMessage(message: RawMessage | undefined): boolean { + if (!message || message.role !== 'user') return false; + const content = message.content; + if (!Array.isArray(content)) return true; + const blocks = content as Array<{ type?: string }>; + return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); +} + +function countBaselineRealUserMessages(messages: RawMessage[]): number { + let count = 0; + for (const message of messages) { + if (isBaselineRealUserMessage(message)) count += 1; + } + return count; +} + +function getBaselineRunKeyForMessages(sessionKey: string, messages: RawMessage[]): string | null { + const userTurnOrdinal = countBaselineRealUserMessages(messages); + return buildBaselineRunKey(sessionKey, userTurnOrdinal); +} + +function captureBaselinesFromMessage(message: unknown, runKey: string | null): void { + if (!runKey || !message || typeof message !== 'object') return; + const content = (message as Record).content; + if (!Array.isArray(content)) return; + for (const block of content as ContentBlock[]) { + if (block.type !== 'tool_use' && block.type !== 'toolCall') continue; + const name = typeof block.name === 'string' ? block.name : ''; + if (!name) continue; + if (!BASELINE_WRITE_TOOLS.has(name) && !BASELINE_EDIT_TOOLS.has(name)) continue; + const input = block.input ?? block.arguments; + const filePath = pickFilePathFromInput(input); + if (filePath) captureBaseline(runKey, filePath); + } +} + function extractTextFromContent(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; @@ -1412,6 +1481,7 @@ export const useChatStore = create((set, get) => ({ // This prevents the poll timer from firing after the switch and loading // the wrong session's history into the new session's view. clearHistoryPoll(); + clearBaselines(); set((s) => buildSessionSwitchPatch(s, key)); get().loadHistory(); }, @@ -1654,13 +1724,26 @@ export const useChatStore = create((set, get) => ({ if (!userMsTs || !msg.timestamp) return true; return toMs(msg.timestamp) >= userMsTs; }; - const latestTerminalAssistantError = [...filteredMessages].reverse().find((msg) => ( - msg.role === 'assistant' - && getMessageStopReason(msg) === 'error' - && isAfterUserMsg(msg) - )); - const latestTerminalAssistantErrorMessage = latestTerminalAssistantError - ? getMessageErrorMessage(latestTerminalAssistantError) + const isRealUserBoundary = (msg: RawMessage): boolean => { + if (msg.role !== 'user') return false; + if (!Array.isArray(msg.content)) return true; + const blocks = msg.content as Array<{ type?: string }>; + return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); + }; + const postBoundaryMessages = userMsTs + ? filteredMessages.filter((msg) => isAfterUserMsg(msg)) + : (() => { + for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundary(filteredMessages[i])) { + return filteredMessages.slice(i + 1); + } + } + return filteredMessages; + })(); + const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant'); + const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary + && getMessageStopReason(lastAssistantAfterBoundary) === 'error' + ? getMessageErrorMessage(lastAssistantAfterBoundary) : null; set({ @@ -2124,6 +2207,12 @@ export const useChatStore = create((set, get) => ({ set({ error: null, runError: null }); } const updates = collectToolUpdates(event.message, resolvedState); + // Capture baseline file content from disk before the runtime + // executes Write tool calls — enables proper before/after diff. + captureBaselinesFromMessage( + event.message, + getBaselineRunKeyForMessages(currentSessionKey, get().messages), + ); set((s) => ({ streamingMessage: (() => { if (event.message && typeof event.message === 'object') { diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index 4ac73ae..b4fd6ac 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -119,13 +119,26 @@ export function createHistoryActions( if (!userMsTs || !msg.timestamp) return true; return toMs(msg.timestamp) >= userMsTs; }; - const latestTerminalAssistantError = [...filteredMessages].reverse().find((msg) => ( - msg.role === 'assistant' - && getMessageStopReason(msg) === 'error' - && isAfterUserMsg(msg) - )); - const latestTerminalAssistantErrorMessage = latestTerminalAssistantError - ? getMessageErrorMessage(latestTerminalAssistantError) + const isRealUserBoundary = (msg: RawMessage): boolean => { + if (msg.role !== 'user') return false; + if (!Array.isArray(msg.content)) return true; + const blocks = msg.content as Array<{ type?: string }>; + return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); + }; + const postBoundaryMessages = userMsTs + ? filteredMessages.filter((msg) => isAfterUserMsg(msg)) + : (() => { + for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundary(filteredMessages[i])) { + return filteredMessages.slice(i + 1); + } + } + return filteredMessages; + })(); + const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant'); + const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary + && getMessageStopReason(lastAssistantAfterBoundary) === 'error' + ? getMessageErrorMessage(lastAssistantAfterBoundary) : null; set({ diff --git a/tests/e2e/chat-file-changes.spec.ts b/tests/e2e/chat-file-changes.spec.ts new file mode 100644 index 0000000..8636bd2 --- /dev/null +++ b/tests/e2e/chat-file-changes.spec.ts @@ -0,0 +1,115 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const SESSION_KEY = 'agent:main:main'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +const history = [ + { + role: 'user', + id: 'user-1', + content: [{ type: 'text', text: 'Patch the workspace file' }], + timestamp: Date.now(), + }, + { + role: 'assistant', + id: 'assistant-tool-1', + content: [{ + type: 'toolCall', + id: 'edit-1', + name: 'Edit', + arguments: { + file_path: '/workspace/demo.ts', + old_string: 'const value = 1\n', + new_string: 'const value = 2\n', + }, + }], + timestamp: Date.now(), + }, + { + role: 'assistant', + id: 'assistant-final-1', + content: [{ type: 'text', text: 'Updated the file.' }], + timestamp: Date.now(), + }, +]; + +test.describe('ClawX chat file changes', () => { + test('shows line stats on generated file cards', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345 }, + gatewayRpc: { + [stableStringify(['sessions.list', {}])]: { + success: true, + result: { + sessions: [{ key: SESSION_KEY, displayName: 'main' }], + }, + }, + [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: { + success: true, + result: { messages: history }, + }, + [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: { + success: true, + result: { messages: history }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345 }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + agents: [{ id: 'main', name: 'main' }], + }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByRole('button', { name: '工作空间' })).toHaveCount(0); + await expect(page.getByText('查看文件变更')).toHaveCount(0); + + const fileCard = page.getByRole('button', { name: /demo\.ts/ }).first(); + await expect(fileCard).toBeVisible({ timeout: 30_000 }); + await expect(fileCard).toContainText('+1'); + await expect(fileCard).toContainText('-1'); + + await fileCard.click(); + await expect(page.locator('aside').getByRole('button', { name: '工作空间' })).toHaveCount(0); + await expect(fileCard).toContainText('demo.ts'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/chat-task-visualizer.spec.ts b/tests/e2e/chat-task-visualizer.spec.ts index fbcfb5a..62f94d0 100644 --- a/tests/e2e/chat-task-visualizer.spec.ts +++ b/tests/e2e/chat-task-visualizer.spec.ts @@ -416,7 +416,6 @@ test.describe('ClawX chat execution graph', () => { await expect(page.getByTestId('main-layout')).toBeVisible(); await expect(page.getByText('404 Resource not found')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('runError.title')).toBeVisible(); await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await expect(page.getByTestId('chat-execution-step-thinking-trailing')).toHaveCount(0); await expect(page.getByText('404 Resource not found')).toHaveCount(1); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index e7a2aea..3abe6b8 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -250,6 +250,37 @@ describe('chat history actions', () => { expect(h.read().lastUserMessageAt).toBeNull(); }); + it('does not surface an earlier assistant error when a later assistant reply succeeded', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + runError: 'Connection error.', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockResolvedValueOnce({ + success: true, + result: { + messages: [ + { role: 'user', content: 'First question', timestamp: 1000 }, + { role: 'assistant', content: [], timestamp: 1001, stopReason: 'error', errorMessage: 'Connection error.' }, + { role: 'user', content: 'Second question', timestamp: 1002 }, + { role: 'assistant', content: 'Recovered answer', timestamp: 1003 }, + ], + }, + }); + + await actions.loadHistory(true); + + expect(h.read().runError).toBeNull(); + expect(h.read().messages.map((message) => message.content)).toEqual([ + 'First question', + [], + 'Second question', + 'Recovered answer', + ]); + }); + it('clears stale runError when refreshed history no longer contains a terminal assistant error', async () => { const { createHistoryActions } = await import('@/stores/chat/history-actions'); const h = makeHarness({ diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx index f58a2b4..b7735d8 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-page-execution-graph.test.tsx @@ -197,6 +197,67 @@ describe('Chat execution graph lifecycle', () => { expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0); }); + it('renders generated file cards with line stats for edit tools', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { + role: 'user', + content: 'Patch the workspace file', + }, + { + role: 'assistant', + id: 'edit-turn', + content: [ + { + type: 'tool_use', + id: 'edit-1', + name: 'Edit', + input: { + file_path: '/workspace/demo.ts', + old_string: 'const value = 1\n', + new_string: 'const value = 2\n', + }, + }, + ], + }, + { + role: 'assistant', + id: 'reply-turn', + content: [{ type: 'text', text: 'Updated the file.' }], + }, + ], + loading: false, + error: null, + runError: null, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + await waitFor(() => { + expect(screen.getByText('demo.ts')).toBeInTheDocument(); + }); + + expect(screen.getByText('+1')).toBeInTheDocument(); + expect(screen.getByText('-1')).toBeInTheDocument(); + }); + it('stops showing trailing thinking and renders run error callout after terminal model error', async () => { const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ diff --git a/tests/unit/generated-files-panel.test.tsx b/tests/unit/generated-files-panel.test.tsx new file mode 100644 index 0000000..31dcd8a --- /dev/null +++ b/tests/unit/generated-files-panel.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel'; +import type { GeneratedFile } from '@/lib/generated-files'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, params?: Record) => { + if (typeof params?.count === 'number') return `文件变更(${params.count} 个)`; + return ''; + }, + }), +})); + +function makeFile(overrides: Partial): GeneratedFile { + return { + filePath: '/tmp/example.ts', + fileName: 'example.ts', + ext: '.ts', + mimeType: 'text/typescript', + contentType: 'code', + action: 'modified', + fullContent: 'const value = 2\n', + lastSeenIndex: 1, + ...overrides, + }; +} + +describe('GeneratedFilesPanel', () => { + it('keeps unsupported document formats non-clickable', () => { + const onOpen = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button', { name: /report\.pdf/ }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('keeps supported text/code formats clickable', () => { + const onOpen = vi.fn(); + const file = makeFile({ filePath: '/tmp/example.ts' }); + render(); + + const button = screen.getByRole('button', { name: /example\.ts/ }); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/example.ts' })); + }); +}); diff --git a/tests/unit/generated-files.test.ts b/tests/unit/generated-files.test.ts new file mode 100644 index 0000000..96e0729 --- /dev/null +++ b/tests/unit/generated-files.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + computeLineStats, + extractGeneratedFiles, + supportsInlineDiff, + supportsInlineDocumentPreview, + type GeneratedFile, + type GeneratedFileBaseline, +} from '@/lib/generated-files'; +import type { RawMessage } from '@/stores/chat'; + +function makeWriteFile(overrides: Partial = {}): GeneratedFile { + return { + filePath: '/tmp/example.ts', + fileName: 'example.ts', + ext: '.ts', + mimeType: 'text/typescript', + contentType: 'code', + action: 'modified', + fullContent: 'const value = 2\nconsole.log(value)\n', + lastSeenIndex: 1, + ...overrides, + }; +} + +describe('generated-files utilities', () => { + it('computes write line stats from an existing-file baseline', () => { + const stats = computeLineStats( + makeWriteFile({ + baseline: { status: 'ok', content: 'const value = 1\nconsole.log(value)\n' }, + }), + ); + + expect(stats).toEqual({ added: 1, removed: 1 }); + }); + + it('treats missing baseline as a new file for line stats', () => { + const stats = computeLineStats( + makeWriteFile({ + action: 'created', + baseline: { status: 'missing' }, + fullContent: 'line 1\nline 2\n', + }), + ); + + expect(stats).toEqual({ added: 2, removed: 0 }); + }); + + it('refuses to fake precise line stats when baseline is unavailable', () => { + const stats = computeLineStats( + makeWriteFile({ + baseline: { status: 'unavailable', reason: 'outsideSandbox' }, + }), + ); + + expect(stats).toBeNull(); + }); + + it('treats pdf/office style documents as unsupported for inline preview and diff', () => { + expect(supportsInlineDocumentPreview('.md')).toBe(true); + expect(supportsInlineDocumentPreview('.pdf')).toBe(false); + expect(supportsInlineDiff({ ext: '.docx', contentType: 'document' })).toBe(false); + + const stats = computeLineStats({ + filePath: '/tmp/report.pdf', + fileName: 'report.pdf', + ext: '.pdf', + mimeType: 'application/pdf', + contentType: 'document', + action: 'modified', + fullContent: 'pretend text payload', + baseline: { status: 'ok', content: 'older pretend text payload' }, + lastSeenIndex: 1, + }); + + expect(stats).toBeNull(); + }); + + it('extracts write files with per-run baseline state and action', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'update file', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'write-1', + name: 'Write', + input: { + file_path: '/tmp/example.ts', + content: 'const value = 2\n', + }, + }], + }, + ]; + + const baselineByPath = new Map([ + ['/tmp/example.ts', { status: 'ok', content: 'const value = 1\n' }], + ]); + + const files = extractGeneratedFiles(messages, 0, 1, (filePath) => baselineByPath.get(filePath)); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + filePath: '/tmp/example.ts', + action: 'modified', + baseline: { status: 'ok', content: 'const value = 1\n' }, + }); + }); + + it('keeps new-file writes marked as created when the baseline says missing', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'create file', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'write-1', + name: 'Write', + input: { + file_path: '/tmp/new-file.ts', + content: 'export const created = true\n', + }, + }], + }, + ]; + + const files = extractGeneratedFiles(messages, 0, 1, () => ({ status: 'missing' })); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + filePath: '/tmp/new-file.ts', + action: 'created', + baseline: { status: 'missing' }, + }); + }); + + it('computes edit snippet stats from joined edit hunks', () => { + const stats = computeLineStats({ + filePath: '/tmp/example.ts', + fileName: 'example.ts', + ext: '.ts', + mimeType: 'text/typescript', + contentType: 'code', + action: 'modified', + edits: [ + { old: 'alpha\n', new: 'beta\n' }, + { old: 'gamma\n', new: 'delta\n' }, + ], + lastSeenIndex: 1, + }); + + expect(stats).toEqual({ added: 2, removed: 2 }); + }); +});