diff --git a/.github/workflows/auto-tag-on-release.yml b/.github/workflows/auto-tag-on-release.yml index 280d23b0..f95bd076 100644 --- a/.github/workflows/auto-tag-on-release.yml +++ b/.github/workflows/auto-tag-on-release.yml @@ -67,7 +67,8 @@ jobs: # dispatch Release / Docker Publish / Desktop Package (workflow_dispatch # is exempt). FnOS FPK is cascaded from Release after the GitHub Release # exists (reuses Release wheel + waits for GHCR image from Docker Publish). - # Desktop builds in parallel; its release job upserts zips onto the same v*. + # Desktop and FnOS upsert their packages onto the same v* GitHub Release + # after it exists (FnOS is dispatched from Release.yml). - name: Trigger Release, Docker Publish, and Desktop Package if: steps.check_tag.outputs.should_publish == 'true' env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9671aa9e..e3826b10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ name: CI +# Pull requests only. GitHub runs PR checks on the merge result, so a push +# build on main would re-test content that the release / feature PR already +# proved green. on: - push: - branches: [main] pull_request: concurrency: @@ -13,6 +14,9 @@ jobs: quality: name: "Python ${{ matrix.python-version }}" runs-on: ubuntu-latest + # Post-release main → develop sync PRs carry main's already-tested tree and + # are auto-merged by the bot before checks finish. See sync-main-to-develop. + if: ${{ !startsWith(github.head_ref, 'chore/sync-develop-after-') }} strategy: fail-fast: false matrix: @@ -40,6 +44,7 @@ jobs: test-windows: name: Windows / Python 3.12 runs-on: windows-latest + if: ${{ !startsWith(github.head_ref, 'chore/sync-develop-after-') }} steps: - uses: actions/checkout@v4 @@ -65,8 +70,8 @@ jobs: runs-on: ubuntu-latest # Only the main repo may access secrets; fork PRs get none and must skip. if: >- - github.event_name == 'push' || - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + !startsWith(github.head_ref, 'chore/sync-develop-after-') steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 23910f47..677ad9e2 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -3,18 +3,19 @@ name: Build Octop FPK # FnOS (飞牛 NAS) 安装包流水线。 # # 触发: -# - Release 工作流在 GitHub Release 创建成功后自动 dispatch(推荐) -# - 亦可手动 workflow_dispatch(需对应版本镜像已在 GHCR) +# - Release 工作流在 GitHub Release(v*)创建成功后自动 dispatch(推荐) +# - 亦可手动 workflow_dispatch(需对应版本镜像已在 GHCR,且 v* Release 已存在) # # 制品复用: # - Docker 镜像:不再重建,等待 docker-publish 推送的 # ghcr.io/tencentcloud/octop:{version|latest} # - Native wheel:优先从同版本 GitHub Release(v*)下载; # 缺失时再回退到本仓源码构建 +# - 产物挂到同一个 v* GitHub Release(与 wheel / 桌面包并列),不再单独打 fnos-* tag # # Jobs: # version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose) -# ↘ native(本地版 .fpk)→ release(按版本号发布 fnos-) +# ↘ native(本地版 .fpk)→ release(把 .fpk 挂到已有的 v* GitHub Release) on: workflow_dispatch: @@ -48,9 +49,9 @@ jobs: IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" echo "version=$VER" >> "$GITHUB_OUTPUT" echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - echo "tag=fnos-${VER}" >> "$GITHUB_OUTPUT" + echo "tag=v${VER}" >> "$GITHUB_OUTPUT" echo "pkg_ver=${VER}" >> "$GITHUB_OUTPUT" - echo "[version] VER=$VER, TAG=fnos-${VER}" + echo "[version] VER=$VER, TAG=v${VER}" # 复用 docker-publish 已推送的镜像,不在此重新 build/push。 ensure-image: @@ -256,52 +257,28 @@ jobs: cp artifacts/docker-fpk/*.fpk dist/ || true cp artifacts/native-fpk/*.fpk dist/ || true ls -lh dist + if ! ls dist/*.fpk >/dev/null 2>&1; then + echo "::error::No .fpk files to attach." + exit 1 + fi - - name: Generate changelog - id: changelog + - name: Require existing v* GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.version.outputs.tag }} run: | - VER="${{ needs.version.outputs.version }}" - IMAGE="${{ needs.version.outputs.image }}" - PREV_TAG="" - for t in $(git tag --list "fnos-*" --sort=-creatordate); do - [ "$t" = "fnos-latest" ] && continue - if git merge-base --is-ancestor "$t" HEAD 2>/dev/null; then - PREV_TAG="$t" - break - fi - done - echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" - { - echo "## 安装说明" - echo "" - echo "1. 飞牛应用中心 → 设置 → 手动安装应用,选择对应 .fpk。" - echo "2. Docker 版 **Octop-fnos-docker-${VER}.fpk**(约 80KB):飞牛自动从 GHCR 拉取 \`${IMAGE}:latest\`。" - echo "3. 本地版 **Octop-fnos-native-${VER}.fpk**(约 200MB):非 Docker,原生运行在飞牛主机;安装时自动关联系统 Python 3.12 开发工具;浏览器、远程桌面等附加组件在 Octop 应用内按需安装。" - echo "" - echo "---" - echo "按版本号下载对应 .fpk 即可(如 \`Octop-fnos-docker-${VER}.fpk\` / \`Octop-fnos-native-${VER}.fpk\`)。" - echo "" - echo "## 本次更新(v${VER})" - echo "" - if [ -n "$PREV_TAG" ]; then - git log --oneline --no-merges "$PREV_TAG"..HEAD | head -30 - else - git log --oneline --no-merges -30 HEAD - fi - } > /tmp/release-body.md - cat /tmp/release-body.md + set -euo pipefail + if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null; then + echo "::error::GitHub Release ${TAG} does not exist yet. Run this after Release.yml creates it." + exit 1 + fi - - name: Publish versioned release + - name: Attach fpk to GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.tag }} - target_commitish: ${{ github.sha }} - name: Octop (FnOS) v${{ needs.version.outputs.pkg_ver }} - body_path: /tmp/release-body.md files: dist/*.fpk - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + fail_on_unmatched_files: true + generate_release_notes: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index df079568..9a8239a5 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -1,10 +1,8 @@ name: Octop Desktop Package -# 正式发版:Auto Tag 在打 v* 后与 Release / Docker Publish 一并 dispatch(GITHUB_TOKEN -# 打的 tag 不会触发 push workflow)。产物由本 workflow 的 release job upsert 到同一 -# GitHub Release。workflow_dispatch 保留给手工补包 / 按平台重跑。 -# 不要对任意分支 push 跑六平台矩阵。 -# pull_request(desktop/**)打 darwin-* + windows-*,不跑 linux。 +# 正式发版:Auto Tag 在打 v* 后与 Release / Docker Publish 一并 dispatch。 +# 产物 upsert 到同一 GitHub Release。手工补包:workflow_dispatch + release_tag +# (不必跑在 v* ref 上)。attach_from_run 只挂已有 artifact、不重打。 on: push: @@ -22,12 +20,12 @@ on: default: "all" type: string attach_release: - description: "If running on a v* tag, also upload zips to that GitHub Release" + description: "Upload artifacts to a GitHub Release (v* ref, or pass release_tag)" required: false default: true type: boolean release_tag: - description: "GitHub Release tag to attach to (e.g. v0.9.27). Defaults to the current v* tag ref." + description: "GitHub Release tag to attach to (e.g. v0.9.31). Used when not running on a v* ref." required: false default: "" type: string @@ -40,9 +38,15 @@ on: permissions: contents: read +# Builds on the same ref cancel each other (PR retrigger / accidental double +# dispatch). Attach-only runs use a different group so they cannot cancel an +# in-flight six-platform package on develop. concurrency: - group: green-portable-${{ github.ref }} - cancel-in-progress: true + group: >- + green-portable-${{ + github.event.inputs.attach_from_run != '' && 'attach' || 'build' + }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' }} env: # Prefer GitHub upstream on Actions runners (npmmirror is for CN local builds). @@ -67,7 +71,7 @@ jobs: - name: Build frontend → src/octop/dashboard run: make build-frontend - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: name: dashboard-dist path: src/octop/dashboard/ @@ -139,7 +143,14 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/download-artifact@v5 + - name: Read package version + run: | + set -euo pipefail + VER=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' pyproject.toml | head -1) + echo "OCTOP_VERSION=${VER}" >> "$GITHUB_ENV" + echo "version=${VER}" + + - uses: actions/download-artifact@v8 with: name: dashboard-dist path: src/octop/dashboard @@ -219,20 +230,31 @@ jobs: - name: Install Wails v3 CLI run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 + - name: Install NSIS + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install nsis --yes --no-progress + $nsis = "${env:ProgramFiles(x86)}\NSIS" + if (-not (Test-Path "$nsis\makensis.exe")) { + throw "makensis.exe missing after NSIS install" + } + Add-Content -Path $env:GITHUB_PATH -Value $nsis + - name: Package desktop app with bundled portable runtime working-directory: desktop/src run: >- wails3 task package ARCH=${{ matrix.arch }} - PORTABLE_ZIP=../portable/release/Octop-${{ matrix.plat }}.zip + VERSION=${{ env.OCTOP_VERSION }} + PORTABLE_ZIP=../portable/release/Octop-portable-${{ matrix.plat }}-${{ env.OCTOP_VERSION }}.zip # archive: false — upload the prebuilt zip as-is. Default archive=true would - # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip - # (Octop-.zip containing another Octop-.zip). Affects every plat. + # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip. # With archive:false, artifact name is the filename (name: is ignored). - uses: actions/upload-artifact@v7 with: - path: desktop/portable/release/Octop-${{ matrix.plat }}.zip + path: desktop/portable/release/Octop-portable-${{ matrix.plat }}-${{ env.OCTOP_VERSION }}.zip archive: false if-no-files-found: error retention-days: 14 @@ -240,7 +262,7 @@ jobs: - name: Upload bundled desktop package uses: actions/upload-artifact@v7 with: - path: desktop/src/bin/Octop-Desktop-${{ matrix.plat }}.* + path: desktop/src/bin/Octop-desktop-${{ matrix.plat }}-${{ env.OCTOP_VERSION }}.* archive: false if-no-files-found: error retention-days: 14 @@ -256,6 +278,9 @@ jobs: ( (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_from_run != '') || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.attach_release == 'true' && + github.event.inputs.release_tag != '') || (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && @@ -281,7 +306,7 @@ jobs: echo "tag=${tag}" >> "$GITHUB_OUTPUT" # v8 required for archive:false artifacts. skip-decompress keeps the - # uploaded .zip / .tar / .dmg / .exe intact — default unzip turns portable + # uploaded .zip / .tar.gz / .dmg / .exe intact — default unzip turns portable # zips into directories, so files: release-assets/* would skip them. - uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48fbe6b1..563a6c19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,6 +122,9 @@ jobs: echo '```' } > .release-notes.md fi + if ! grep -q '^## Downloads' .release-notes.md; then + python3 scripts/release_download_links.py "$version" >> .release-notes.md + fi - name: Create release uses: softprops/action-gh-release@v2 @@ -134,13 +137,12 @@ jobs: # started via GITHUB_TOKEN workflow_dispatch (Auto Tag). Explicitly cascade. # # This job intentionally has no checkout: pass --repo so `gh` does not need a - # local .git (bare runner → "fatal: not a git repository"). continue-on-error - # keeps a cascade miss from marking PyPI/GitHub release as failed. + # local .git (bare runner → "fatal: not a git repository"). Dispatch failure + # must fail this workflow so a missed sync is visible. sync-develop: name: Trigger sync main into develop needs: github-release runs-on: ubuntu-latest - continue-on-error: true steps: - name: Dispatch Sync Main Into Develop env: @@ -148,15 +150,15 @@ jobs: run: | gh workflow run sync-main-to-develop.yml \ --repo "${{ github.repository }}" \ - --ref main + --ref main \ + -f version="${{ github.ref_name }}" # FnOS FPK:复用本 Release 的 wheel + docker-publish 的 GHCR 镜像。 - # 与 sync-develop 并行;失败不阻断发版。 + # 与 sync-develop 并行。Dispatch 失败必须让本 workflow 变红(PyPI 已发布仍如此)。 trigger-fnos: name: Trigger FnOS FPK build needs: github-release runs-on: ubuntu-latest - continue-on-error: true steps: - name: Dispatch Build Octop FPK env: diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml index 86055936..f6cd712f 100644 --- a/.github/workflows/sync-main-to-develop.yml +++ b/.github/workflows/sync-main-to-develop.yml @@ -2,26 +2,21 @@ name: Sync Main Into Develop # After Release succeeds, sync main → develop. # -# Primary trigger: Release.yml explicitly dispatches this workflow (see -# sync-develop job). Do not rely only on `release: published` or -# `workflow_run` — when Auto Tag starts Release with GITHUB_TOKEN, those -# events often never create a Sync run. workflow_dispatch remains for -# manual catch-up; workflow_run / release are kept as best-effort backups. +# Only trigger: Release.yml (and humans) dispatch this workflow. Do not also +# listen for `release: published` or `workflow_run` — those duplicate the +# explicit dispatch and force-push the same sync branch. # # Strategy: keep main an ancestor of develop. Try merge first; on conflict, # rebuild on main and open a PR (develop is often branch-protected against # force-push). Never use head=main merge PRs — that pattern conflicts when # main was updated via a bulk develop merge (#150-style divergence). on: - workflow_run: - workflows: - - Release - types: - - completed workflow_dispatch: - release: - types: - - published + inputs: + version: + description: "Release tag being synced, e.g. v0.9.31. Blank = read main's pyproject.toml." + required: false + type: string permissions: contents: write @@ -30,12 +25,6 @@ permissions: jobs: sync: name: Sync main into develop - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v')) || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest steps: - name: Checkout @@ -47,14 +36,23 @@ jobs: - name: Sync develop onto main env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.event.release.tag_name || github.event.workflow_run.head_branch || 'manual' }} + TAG: ${{ inputs.version }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin main develop + + # Prefer the version input from Release.yml. Fall back to main's + # shipped pyproject so a blank dispatch still gets a unique branch + # name instead of colliding across releases. version="${TAG#v}" + if [ -z "$version" ]; then + version="$(git show origin/main:pyproject.toml \ + | sed -nE 's/^version = "([^"]+)".*/\1/p' | head -1)" + fi + : "${version:?could not resolve the version being synced}" sync_branch="chore/sync-develop-after-${version}" - git fetch origin main develop if git merge-base --is-ancestor origin/main origin/develop; then echo "develop already contains main tip; nothing to sync." @@ -118,7 +116,7 @@ jobs: --base develop \ --head "${sync_branch}" \ --title "chore: sync develop onto main after ${version}" \ - --body "Post-release sync so \`main\` stays an ancestor of \`develop\` (${TAG}). Merge when CI is green." + --body "Post-release sync so \`main\` stays an ancestor of \`develop\` (v${version}). Carries main's already-tested tree, so CI is skipped for this branch." )" echo "Created sync PR: ${url}" diff --git a/.gitignore b/.gitignore index 779e5c26..b1551df5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Build +build/ +!desktop/src/build/ +!desktop/src/build/** +dist/ + # OS .DS_Store .DS_Store? @@ -19,10 +25,6 @@ __pycache__/ *$py.class *.so .Python -build/ -!desktop/src/build/ -!desktop/src/build/** -dist/ *.egg-info/ *.egg .eggs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 75919659..8a8dcb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ ## [Unreleased] +## [0.9.32] - 2026-09-06 + +### 新增 + +- 聊天运行轨迹抽屉与回合时间轴;人机确认以可读审批卡片展示 +- 知识库支持更多文档格式与可选 OCR,上传显示进度 +- 定时任务支持名称,计划回合持久化 +- 备份可选择归档内容;连接器支持多实例与共享;插件可按智能体开关 +- OpenSandbox 远程沙箱;工作台浏览器按用户隔离 profile +- 飞书扫码创建应用;远程手机可自动安装 Docker +- 桌面端 Windows NSIS 安装包与 macOS DMG;未知路由 404 页 + +### 修复 + +- 旧备份在 schema 变更后可恢复;桌面打包版本与应用元数据对齐 +- 主动关怀时区、专家卡片头像、用户表固定列 +- 运行轨迹写库开销、日志轮转、通道二维码轮询与远程 Docker 安装权限 + +### 变更 + +- 语音与搜索设置迁到模型页;控制台改用 OctopSpinner +- 默认管理员凭据改为首次运行写入 `~/octop-login.txt` + ## [0.9.31] - 2026-09-01 ### 新增 diff --git a/README.md b/README.md index 3b5c2b85..5fa76ec2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars @@ -135,12 +135,6 @@ Here are our mid-to-long term plans: This roadmap may shift as the community grows; treat it as indicative only. - -## 🖥️ FnOS (飞牛 NAS) Installation - -Octop can be installed on FnOS (飞牛 NAS) devices via the App Center as a `.fpk` package, either as a Docker-backed app or as a native (non-Docker) app. See [`fnos/README.md`](fnos/README.md) for the full packaging guide, the FPK build helper (`scripts/build-fpk.sh`), and the CI pipeline template (`.github/workflows/fnos-build-fpk.yml`). - -Initial admin credentials: `admin` / `Octop123` (change after first login). For non-root FnOS installs, the WeCom/Feishu connector CLIs require the fix in [PR #406](https://github.com/TencentCloud/Octop/pull/406) (user-level npm fallback). ## 🚀 Quick Start ### Prerequisites @@ -189,6 +183,17 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install See [scripts/README.md](scripts/README.md) for all install options (`--version`, `--from-source`, `--mirror`, Windows flags). +**Desktop app** (GUI, no terminal) — grab the artifact for your platform from [GitHub Releases](https://github.com/TencentCloud/Octop/releases/latest): + +| Platform | Artifact | +|----------|----------| +| Windows | `Octop-desktop-windows-amd64-.exe` (64-bit) / `Octop-desktop-windows-arm64-.exe` (ARM64) — NSIS installer | +| macOS | `Octop-desktop-darwin-arm64-.dmg` (Apple Silicon) / `Octop-desktop-darwin-amd64-.dmg` (Intel) | +| Linux | `Octop-desktop-linux-amd64-.tar.gz` / `Octop-desktop-linux-arm64-.tar.gz` | +| FnOS NAS | `Octop-fnos-docker-.fpk` (Docker-backed) / `Octop-fnos-native-.fpk` (no Docker) — install via App Center | + +See [desktop/README.md](desktop/README.md) for the desktop shell and [fnos/README.md](fnos/README.md) for the FnOS packaging guide. + **Alternative — PyPI** (if you already manage Python yourself): ```bash @@ -243,9 +248,9 @@ docker run -d \ octop:latest ``` -Open `http://localhost:8088` — default credentials are `admin` / `Octop123` (change immediately). Credentials are also written to `/data/.octop/credential.txt` on first boot. +Open `http://localhost:8088`. First boot creates an admin account with fixed default credentials `admin` / `Octop123` (written to `/data/.octop/credential.txt` in the container) — **not** a randomly generated password. Override the defaults via `OCTOP_ADMIN_USERNAME` / `OCTOP_DEFAULT_PASSWORD`. -> **Password policy:** at least 8 characters with letters and digits. A future release may replace the fixed Docker default with a randomly generated password written only to `credential.txt`. +> **Password policy:** at least 8 characters with letters and digits. | Variable | Default | Description | |----------|---------|-------------| diff --git a/README_CN.md b/README_CN.md index 9fc11fa3..5437e481 100644 --- a/README_CN.md +++ b/README_CN.md @@ -194,6 +194,17 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install 完整安装选项见 [scripts/README.md](scripts/README.md)(`--version`、`--from-source`、`--mirror` 及 Windows 参数)。 +**桌面客户端**(图形界面,无需终端)— 从 [GitHub Releases](https://github.com/TencentCloud/Octop/releases/latest) 下载对应平台的安装包: + +| 平台 | 制品 | +|------|------| +| Windows | `Octop-desktop-windows-amd64-.exe`(64 位)/ `Octop-desktop-windows-arm64-.exe`(ARM64)— NSIS 安装程序 | +| macOS | `Octop-desktop-darwin-arm64-.dmg`(Apple 芯片)/ `Octop-desktop-darwin-amd64-.dmg`(Intel) | +| Linux | `Octop-desktop-linux-amd64-.tar.gz` / `Octop-desktop-linux-arm64-.tar.gz` | +| 飞牛 NAS(FnOS) | `Octop-fnos-docker-.fpk`(依赖 Docker)/ `Octop-fnos-native-.fpk`(无需 Docker)— 通过应用中心安装 | + +桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。 + **备选 — PyPI**(若你已自行管理 Python 环境): ```bash @@ -224,8 +235,6 @@ octop service start 打开 **http://127.0.0.1:8088**。Docker 首次初始化默认账号为 `admin` / `Octop123`,请立即修改密码。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。 -> ⚠️ **安全提醒:** Docker 默认管理员密码为 `Octop123`,首次启动后请尽快在「个人设置 → 修改密码」中更换,避免服务暴露到公网时被未授权访问。 - ### Docker(推荐用于生产部署) ```bash @@ -242,9 +251,9 @@ docker run -d \ octop:latest ``` -打开 `http://localhost:8088` — 默认账号 `admin` / `Octop123`(请立即修改密码)。首次启动也会把凭据写入 `/data/.octop/credential.txt`。 +打开 `http://localhost:8088`。首次初始化会以固定默认凭据创建管理员:`admin` / `Octop123`(并写入容器内 `/data/.octop/credential.txt`)——**并非随机生成**。可通过 `OCTOP_ADMIN_USERNAME` / `OCTOP_DEFAULT_PASSWORD` 覆盖默认值。 -> **密码策略:** 至少 8 位,且同时包含字母和数字。后续计划改为首次启动随机生成密码,并仅写入 `credential.txt`。 +> **密码策略:** 至少 8 位,且同时包含字母和数字。 | 变量 | 默认值 | 说明 | |------|--------|------| diff --git a/dashboard/public/octop-mascot-empty.png b/dashboard/public/octop-mascot-empty.png new file mode 100644 index 00000000..80cdad90 Binary files /dev/null and b/dashboard/public/octop-mascot-empty.png differ diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 4e353bc9..217894fd 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -4,7 +4,10 @@ import zhCN from "antd/locale/zh_CN"; import enUS from "antd/locale/en_US"; import { useEffect } from "react"; import DesktopWindowControls from "./components/DesktopWindowControls"; -import { useDesktopChrome } from "./hooks/useDesktopChrome"; +import { + DesktopChromeProvider, + useDesktopChrome, +} from "./hooks/useDesktopChrome"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { useTranslation } from "react-i18next"; import MainLayout from "./layouts/MainLayout"; @@ -13,6 +16,7 @@ import OidcComplete from "./pages/Login/OidcComplete"; import SetupPage from "./pages/Setup"; import InvitePage from "./pages/Invite"; import AuthGuard from "./components/AuthGuard"; +import OctopSpinner from "./components/OctopSpinner"; import { AntdAppProvider } from "./components/AntdAppProvider"; import GlobalErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider, useTheme } from "./context/ThemeContext"; @@ -26,6 +30,7 @@ import { brandTokensFor } from "./styles/themePalettes"; import "./styles/theme-vars.css"; import "./styles/layout.css"; import "./styles/form-override.css"; +import "./styles/spin-override.css"; const GlobalStyle = createGlobalStyle` * { @@ -111,31 +116,38 @@ function ThemedApp() { }; return ( - + }} + > - {desktopChrome ? ( - - ) : null} - - } /> - } /> - } /> - } /> - - - - - - - - - - } - /> - + + {desktopChrome ? ( + + ) : null} + + } /> + } /> + } /> + } /> + + + + + + + + + + } + /> + + ); diff --git a/dashboard/src/api/modules/backup.ts b/dashboard/src/api/modules/backup.ts index 2496ba83..ff83b3bc 100644 --- a/dashboard/src/api/modules/backup.ts +++ b/dashboard/src/api/modules/backup.ts @@ -5,6 +5,12 @@ export interface BackupFileItem { size: number; modified_at: string; created_at: string; + includes_config?: boolean; + includes_workspaces?: boolean; + includes_skill_packages?: boolean; + includes_plugins?: boolean; + includes_knowledge?: boolean; + includes_chats?: boolean; } export interface BackupListResponse { @@ -16,9 +22,24 @@ export interface AutoBackupSettings { auto_enabled: boolean; schedule: string; retention_count: number; + include_config: boolean; + include_workspaces: boolean; + include_skill_packages: boolean; + include_plugins: boolean; + include_knowledge: boolean; + include_chats: boolean; scheduled?: boolean; } +export interface CreateBackupOptions { + include_config: boolean; + include_workspaces: boolean; + include_skill_packages: boolean; + include_plugins: boolean; + include_knowledge: boolean; + include_chats: boolean; +} + export type BackupOperationKind = "create" | "restore" | "auto" | "export"; export interface BackupStatusResponse { @@ -37,6 +58,12 @@ export const backupApi = { auto_enabled: boolean; schedule: string; retention_count: number; + include_config: boolean; + include_workspaces: boolean; + include_skill_packages: boolean; + include_plugins: boolean; + include_knowledge: boolean; + include_chats: boolean; }) => request<{ ok: boolean } & AutoBackupSettings>("/admin/backup/auto", { method: "PUT", @@ -48,9 +75,10 @@ export const backupApi = { method: "POST", }), - createBackup: () => + createBackup: (options: CreateBackupOptions) => request<{ ok: boolean; item: BackupFileItem }>("/admin/backup/create", { method: "POST", + body: JSON.stringify(options), }), downloadBackup: (filename: string): Promise => diff --git a/dashboard/src/api/modules/browser.ts b/dashboard/src/api/modules/browser.ts index dccce0d8..26287bc8 100644 --- a/dashboard/src/api/modules/browser.ts +++ b/dashboard/src/api/modules/browser.ts @@ -18,9 +18,13 @@ import type { // -- Browser environment types -- export interface BrowserEnvStatus { - installed: boolean; - browser_type: "system" | "playwright" | null; - path: string | null; + playwright: boolean; + browsers_ok: boolean; + harness_browser: boolean; + playwright_chromium?: boolean; + chrome_path?: string | null; + chrome_source?: "system" | "playwright" | null; + error: string | null; } function streamBrowserSse( @@ -121,14 +125,8 @@ export const browserApi = { // -- Sessions -- - getSessions: (conversationId?: string) => { - const path = conversationId - ? `/browser/harness-sessions?conversation_id=${encodeURIComponent( - conversationId, - )}` - : "/browser/harness-sessions"; - return request(path); - }, + getSessions: () => + request("/browser/harness-sessions"), handoff: (sessionId: string, target: "agent" | "user", reason = "") => request<{ ok: boolean; session: BrowserSession }>( @@ -140,12 +138,10 @@ export const browserApi = { ), /** Stop the local Chrome process. Login cookies stay in the on-disk profile. */ - shutdown: (profile?: string) => { - const path = profile - ? `/browser/shutdown?profile=${encodeURIComponent(profile)}` - : "/browser/shutdown"; - return request<{ ok: boolean; profile: string }>(path, { method: "POST" }); - }, + shutdown: () => + request<{ ok: boolean; profile: string }>("/browser/shutdown", { + method: "POST", + }), // -- Browser stream (WebSocket CDP screencast, ~10 fps) -- @@ -177,44 +173,6 @@ export const browserApi = { return new WebSocket(wsUrl); }, - // -- Tabs (REST fallback when WebSocket is unavailable) -- - - /** - * Switch the active tab by page_id. - */ - switchTab: (sessionId: string, pageId: string) => - request<{ ok: boolean; page_id: string; url: string }>( - `/browser/sessions/${sessionId}/tabs/switch`, - { - method: "POST", - body: JSON.stringify({ page_id: pageId }), - }, - ), - - /** - * Create a new browser tab, optionally navigating to a URL. - */ - newTab: (sessionId: string, url = "about:blank") => - request<{ ok: boolean; page_id: string; url: string; tabs: TabInfo[] }>( - `/browser/sessions/${sessionId}/tabs/new`, - { - method: "POST", - body: JSON.stringify({ url }), - }, - ), - - /** - * Close a browser tab by page_id. - */ - closeTab: (sessionId: string, pageId: string) => - request<{ ok: boolean; closed: string; tabs: TabInfo[] }>( - `/browser/sessions/${sessionId}/tabs/close`, - { - method: "POST", - body: JSON.stringify({ page_id: pageId }), - }, - ), - // -- Browser record/replay -- recordReplayStatus: () => @@ -260,11 +218,3 @@ export const browserApi = { }, ), }; - -// -- Tab info type -- -export interface TabInfo { - page_id: string; - url: string; - title?: string; - active: boolean; -} diff --git a/dashboard/src/api/modules/channel.ts b/dashboard/src/api/modules/channel.ts index ffc5ef07..b9965562 100644 --- a/dashboard/src/api/modules/channel.ts +++ b/dashboard/src/api/modules/channel.ts @@ -204,7 +204,7 @@ export const channelApi = { current?: number; total?: number; }>; - qr_token?: string; + qr_url?: string; app_id?: string; app_secret?: string; return_code?: number; diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 4b2dde42..21d9ad99 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -43,11 +43,17 @@ export interface ConnectorInstance { instance_id: string; kind: string; display_name: string; + description?: string | null; status: string; mcp_server_name: string; has_credentials: boolean; /** When true, chat composer pre-selects this connector. */ default_open?: boolean; + shared: boolean; + owner_user_id: number; + owner_username?: string | null; + owner_display_name?: string | null; + can_manage: boolean; created_at: number; updated_at: number; } @@ -129,6 +135,7 @@ export interface CustomMcpServerSpec { display_name?: string; /** When true, chat composer pre-selects this MCP server. */ default_open?: boolean; + shared?: boolean; oauth?: CustomMcpOAuthPreview; } @@ -189,8 +196,10 @@ export const connectorsApi = { createInstance: (body: { kind: string; display_name: string; + description?: string; credentials: Record; default_open?: boolean; + shared?: boolean; }) => request("/connector-instances", { method: "POST", @@ -204,7 +213,14 @@ export const connectorsApi = { patchInstance: ( instanceId: string, - body: { status?: "active" | "disabled"; default_open?: boolean }, + body: { + status?: "active" | "disabled"; + default_open?: boolean; + display_name?: string; + description?: string; + credentials?: Record; + shared?: boolean; + }, ) => request( `/connector-instances/${encodeURIComponent(instanceId)}`, @@ -347,7 +363,7 @@ export const connectorsApi = { patchCustomMcpServer: ( name: string, - body: { enabled?: boolean; default_open?: boolean }, + body: { enabled?: boolean; default_open?: boolean; shared?: boolean }, ) => request<{ servers: CustomMcpServers }>( `/connectors/custom-mcp/servers/${encodeURIComponent(name)}`, diff --git a/dashboard/src/api/modules/expertMarket.ts b/dashboard/src/api/modules/expertMarket.ts index 4072eadf..c017d531 100644 --- a/dashboard/src/api/modules/expertMarket.ts +++ b/dashboard/src/api/modules/expertMarket.ts @@ -70,6 +70,7 @@ export interface CreateMarketExpertBody { temperature?: number | null; top_p?: number | null; max_tokens?: number | null; + enable_trajectory?: boolean; } function hubListPath(query: string, scene: string): string { diff --git a/dashboard/src/api/modules/knowledgeBases.test.ts b/dashboard/src/api/modules/knowledgeBases.test.ts index 9c3b9ec9..3c9eff6a 100644 --- a/dashboard/src/api/modules/knowledgeBases.test.ts +++ b/dashboard/src/api/modules/knowledgeBases.test.ts @@ -73,6 +73,7 @@ describe("knowledgeBasesApi", () => { "/knowledge-bases/kb-1/documents", expect.any(FormData), { method: "POST" }, + undefined, ); expect(request).toHaveBeenNthCalledWith( 1, @@ -84,4 +85,18 @@ describe("knowledgeBasesApi", () => { "/knowledge-bases/kb-1/documents/doc-1/preview", ); }); + + it("forwards the upload progress handler", () => { + const file = new File(["document"], "notes.md", { type: "text/markdown" }); + const onProgress = vi.fn(); + + knowledgeBasesApi.uploadDocument("kb-1", file, "docs/notes.md", onProgress); + + expect(requestUpload).toHaveBeenCalledWith( + "/knowledge-bases/kb-1/documents", + expect.any(FormData), + { method: "POST" }, + onProgress, + ); + }); }); diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index d8cfb256..489755ac 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -19,6 +19,18 @@ export interface KnowledgeCapability { deps_available: boolean; provider_ready: boolean; }; + ocr: { + enabled: boolean; + backend: "onnx" | "remote"; + model: string; + provider_id: string; + prerequisites_ok: boolean; + usable: boolean; + checks: { + deps_available: boolean; + provider_ready: boolean; + }; + }; limits?: KnowledgeLimits; } @@ -76,6 +88,15 @@ export interface KnowledgeEmbeddingOptions { }[]; } +export interface KnowledgeOcrOptions { + local: { id: "rapidocr"; name: string }; + remote: { + provider_id: string; + provider_name: string; + models: { id: string; name: string }[]; + }[]; +} + export interface KnowledgeOnnxDownloadState { status: "idle" | "downloading" | "loading" | "done" | "failed"; progress: number; @@ -99,6 +120,10 @@ export const knowledgeBasesApi = { backend?: "onnx" | "remote"; model?: string; provider_id?: string; + ocr_enabled?: boolean; + ocr_backend?: "onnx" | "remote"; + ocr_model?: string; + ocr_provider_id?: string; }) => request("/knowledge-bases/feature", { method: "PUT", @@ -113,6 +138,9 @@ export const knowledgeBasesApi = { : "/knowledge-bases/embedding-options", ), + getOcrOptions: () => + request("/knowledge-bases/ocr-options"), + downloadOnnx: (model: string) => request("/knowledge-bases/onnx-download", { method: "POST", @@ -224,7 +252,12 @@ export const knowledgeBasesApi = { }, ), - uploadDocument: (id: string, file: File, relativePath?: string) => { + uploadDocument: ( + id: string, + file: File, + relativePath?: string, + onProgress?: (percent: number) => void, + ) => { const body = new FormData(); body.append("upload", file); if (relativePath) body.append("path", relativePath); @@ -232,6 +265,7 @@ export const knowledgeBasesApi = { `/knowledge-bases/${id}/documents`, body, { method: "POST" }, + onProgress, ); }, diff --git a/dashboard/src/api/modules/plugins.ts b/dashboard/src/api/modules/plugins.ts index 636acfad..ec799a93 100644 --- a/dashboard/src/api/modules/plugins.ts +++ b/dashboard/src/api/modules/plugins.ts @@ -43,6 +43,7 @@ export interface AgentPluginTool { export type AgentPluginsConfig = Record< string, { + enabled?: boolean; tools?: Record< string, { @@ -53,6 +54,21 @@ export type AgentPluginsConfig = Record< } >; +export interface AgentPlugin { + id: string; + version?: string | null; + name?: string | null; + kind?: string | null; + description?: string | null; + icon?: string | null; + loaded: boolean; + global_enabled: boolean; + agent_enabled: boolean; + /** Effective state: global and Agent switches must both be enabled. */ + enabled: boolean; + tools: InstalledPlugin["tools"]; +} + export const pluginsApi = { list(): Promise { return request("/plugins"); @@ -110,4 +126,18 @@ export const pluginsApi = { body: JSON.stringify({ plugins }), }); }, + + listAgentPlugins(agentId: string): Promise<{ plugins: AgentPlugin[] }> { + return request(`/plugins/agents/${encodeURIComponent(agentId)}`); + }, + + patchAgentPlugins( + agentId: string, + plugins: Record, + ): Promise<{ plugins: AgentPlugin[] }> { + return request(`/plugins/agents/${encodeURIComponent(agentId)}`, { + method: "PATCH", + body: JSON.stringify({ plugins }), + }); + }, }; diff --git a/dashboard/src/api/modules/publishedExperts.ts b/dashboard/src/api/modules/publishedExperts.ts index 5f03f650..72e053af 100644 --- a/dashboard/src/api/modules/publishedExperts.ts +++ b/dashboard/src/api/modules/publishedExperts.ts @@ -9,6 +9,7 @@ export interface PublishedExpert { creator_username: string | null; source_agent_id: string | null; icon_name: string | null; + icon_url?: string | null; color: string | null; created_at: string; updated_at: string; @@ -38,6 +39,7 @@ export interface InstallPublishedExpertBody { temperature?: number | null; top_p?: number | null; max_tokens?: number | null; + enable_trajectory?: boolean; } export interface InstalledPublishedExpert { diff --git a/dashboard/src/api/modules/trajectory.ts b/dashboard/src/api/modules/trajectory.ts new file mode 100644 index 00000000..bd1253e9 --- /dev/null +++ b/dashboard/src/api/modules/trajectory.ts @@ -0,0 +1,111 @@ +import { getApiUrl } from "../config"; +import { getAuthToken, request, requestBlob } from "../request"; + +export type TrajectoryKind = + | "user" + | "assistant" + | "tool" + | "context" + | "compacted" + | "system" + | "unknown"; + +export interface TrajectoryEvent { + event_id: string; + thread_id: string; + agent_id: string; + seq: number; + ts: number; + kind: TrajectoryKind; + turn_id: string | null; + request_seq: number | null; + is_error: boolean; + summary: string; + payload: Record; +} + +export interface TrajectoryHistory { + thread_id: string; + events: TrajectoryEvent[]; + next_before_seq: number | null; + has_more: boolean; +} + +export interface TrajectoryMetrics { + turns: number; + steps: number; + llm_duration_ms: number | null; + tool_duration_ms: number | null; + ttft_avg_ms: number | null; + tok_per_s: number | null; + cache_hit_ratio: number | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; +} + +export type TrajectoryExportFormat = "jsonl" | "json"; + +function trajectoryBase(agentId: string, threadId: string): string { + return `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent( + threadId, + )}/trajectory`; +} + +export const trajectoryApi = { + history: ( + agentId: string, + threadId: string, + params: { + limit?: number; + beforeSeq?: number; + kinds?: string[]; + } = {}, + ) => { + const search = new URLSearchParams(); + if (params.limit != null) search.set("limit", String(params.limit)); + if (params.beforeSeq != null) { + search.set("before_seq", String(params.beforeSeq)); + } + if (params.kinds != null && params.kinds.length > 0) { + search.set("kinds", params.kinds.join(",")); + } + const qs = search.toString(); + return request( + `${trajectoryBase(agentId, threadId)}${qs ? `?${qs}` : ""}`, + ); + }, + + event: (agentId: string, threadId: string, eventId: string) => + request( + `${trajectoryBase(agentId, threadId)}/events/${encodeURIComponent( + eventId, + )}`, + ), + + metrics: (agentId: string, threadId: string) => + request(`${trajectoryBase(agentId, threadId)}/metrics`), + + export: ( + agentId: string, + threadId: string, + format: TrajectoryExportFormat = "jsonl", + ) => + requestBlob( + `${trajectoryBase(agentId, threadId)}/export?format=${encodeURIComponent( + format, + )}`, + ), + + /** Full URL for EventSource. JWT goes in ``access_token`` (no Authorization header). */ + streamUrl: (agentId: string, threadId: string, afterSeq?: number) => { + const search = new URLSearchParams(); + if (afterSeq != null) search.set("after_seq", String(afterSeq)); + const token = getAuthToken(); + if (token) search.set("access_token", token); + const qs = search.toString(); + return getApiUrl( + `${trajectoryBase(agentId, threadId)}/stream${qs ? `?${qs}` : ""}`, + ); + }, +}; diff --git a/dashboard/src/api/modules/voice.ts b/dashboard/src/api/modules/voice.ts index 741c0e3c..8562a641 100644 --- a/dashboard/src/api/modules/voice.ts +++ b/dashboard/src/api/modules/voice.ts @@ -28,6 +28,16 @@ export interface ActiveVoice { tts: string; } +export interface VoiceProviderInput { + name: string; + kind: string; + capability: string; + base_url?: string | null; + api_key?: string | null; + extra_json?: string | null; + note?: string | null; +} + function recordingFilename(type: string): string { const lower = type.toLowerCase(); if (lower.includes("mp4")) return "recording.m4a"; @@ -83,15 +93,7 @@ export const voiceApi = { ...(provider ? { provider } : {}), }), }), - createProvider: (body: { - name: string; - kind: string; - capability: string; - base_url?: string | null; - api_key?: string | null; - extra_json?: string | null; - note?: string | null; - }) => + createProvider: (body: VoiceProviderInput) => request("/admin/voice/providers", { method: "POST", body: JSON.stringify(body), @@ -122,4 +124,12 @@ export const voiceApi = { body: JSON.stringify({ mode }), }, ), + testConfiguration: (body: VoiceProviderInput & { mode: "stt" | "tts" }) => + request<{ ok: boolean; error?: string }>( + "/admin/voice/providers/test-configuration", + { + method: "POST", + body: JSON.stringify(body), + }, + ), }; diff --git a/dashboard/src/api/types/cronjob.ts b/dashboard/src/api/types/cronjob.ts index e08f4f96..b701138e 100644 --- a/dashboard/src/api/types/cronjob.ts +++ b/dashboard/src/api/types/cronjob.ts @@ -67,6 +67,7 @@ export type CronJobViewLegacy = Record; */ export interface OctopCronRow { id: string; + name: string; agent_id: string; trigger: string; prompt: string; @@ -83,10 +84,12 @@ export interface OctopCronRow { /** Body sent to POST /api/agents/:id/cron */ export interface OctopCronCreateBody { + name?: string | null; trigger: string; prompt: string; session_key?: string | null; fresh_thread?: boolean; + enabled?: boolean; model?: string | null; task_type?: "text" | "agent"; mcp_servers?: string[]; @@ -94,6 +97,7 @@ export interface OctopCronCreateBody { /** Body sent to PATCH /api/agents/:id/cron/:cron_id */ export interface OctopCronPatchBody { + name?: string | null; trigger?: string; prompt?: string; session_key?: string | null; diff --git a/dashboard/src/assets/mascot.ts b/dashboard/src/assets/mascot.ts index ed3fb9fe..d8ae9a9a 100644 --- a/dashboard/src/assets/mascot.ts +++ b/dashboard/src/assets/mascot.ts @@ -2,4 +2,4 @@ * Shared empty-state mascot (served from ``dashboard/public``). * Prefer ``OctopEmptyMascot`` / ``EmptyState variant="mascot"`` over hardcoding. */ -export const OCTOP_EMPTY_MASCOT_SRC = "/octop-mascot-tasks.png"; +export const OCTOP_EMPTY_MASCOT_SRC = "/octop-mascot-empty.png"; diff --git a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less index 2b96a808..2a7c3fdd 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less +++ b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less @@ -248,6 +248,11 @@ min-width: 0; } +.windowControlsSpacer { + flex-shrink: 0; + height: 1px; +} + .toolbarIconBtn { display: inline-flex; align-items: center; diff --git a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.test.tsx b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.test.tsx new file mode 100644 index 00000000..e232daf5 --- /dev/null +++ b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.test.tsx @@ -0,0 +1,85 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DesktopChromeProvider } from "../../hooks/useDesktopChrome"; +import { + DOCK_WINDOW_CONTROLS_PAD_PX, + WINDOW_CONTROLS_INSET, + WINDOW_CONTROLS_SPACER_ATTR, + resolveDesktopChromeStyle, +} from "../../utils/desktopChrome"; +import ChatDockPanelShell from "./ChatDockPanelShell"; + +const baseProps = { + onModeChange: vi.fn(), + onClose: vi.fn(), + children:

dock-body
, +}; + +function renderShell( + mode: "right" | "popup" | "bottom", + chrome: "mac" | "windows" | null, +) { + return render( + + + , + ); +} + +function expectedDockSpacer(chrome: "mac" | "windows"): string { + return `${WINDOW_CONTROLS_INSET[chrome] - DOCK_WINDOW_CONTROLS_PAD_PX}px`; +} + +function spacerWidth(container: HTMLElement): string | undefined { + const el = container.querySelector( + `[${WINDOW_CONTROLS_SPACER_ATTR}]`, + ) as HTMLElement | null; + return el?.style.width; +} + +describe("ChatDockPanelShell chrome inset", () => { + it("inserts a pixel spacer so right-dock and popup toolbars clear window controls", () => { + const { container, rerender } = renderShell("right", "mac"); + expect(spacerWidth(container)).toBe(expectedDockSpacer("mac")); + + rerender( + + + , + ); + expect(spacerWidth(container)).toBe(expectedDockSpacer("windows")); + + rerender( + + + , + ); + expect( + container.querySelector(`[${WINDOW_CONTROLS_SPACER_ATTR}]`), + ).toBeNull(); + }); + + it("does not reserve window-control space outside the desktop shell", () => { + const { container } = renderShell("right", null); + expect( + container.querySelector(`[${WINDOW_CONTROLS_SPACER_ATTR}]`), + ).toBeNull(); + }); + + it("still inserts the spacer from the Wails bridge when chrome context is missing", () => { + Object.defineProperty(window, "_wails", { + configurable: true, + value: { invoke: () => undefined }, + }); + const { container } = render( + , + ); + expect(spacerWidth(container)).toBe( + `${ + WINDOW_CONTROLS_INSET[resolveDesktopChromeStyle()] - + DOCK_WINDOW_CONTROLS_PAD_PX + }px`, + ); + delete (window as Window & { _wails?: unknown })._wails; + }); +}); diff --git a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx index efd06497..faf02891 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx +++ b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx @@ -20,6 +20,14 @@ import { } from "lucide-react"; import { beginPointerDragSession } from "../../hooks/usePointerDragSession"; import type { PanelMode } from "./index"; +import { useDesktopChromeStyle } from "../../hooks/useDesktopChrome"; +import { + DOCK_WINDOW_CONTROLS_PAD_PX, + isDesktopShell, + resolveDesktopChromeStyle, + WINDOW_CONTROLS_SPACER_ATTR, + windowControlsEndSpacerPx, +} from "../../utils/desktopChrome"; import styles from "./ChatBrowserPanel.module.less"; interface ChatDockPanelShellProps { @@ -112,6 +120,10 @@ const ChatDockPanelShell: React.FC = ({ children, }) => { const { t } = useTranslation(); + const chromeFromContext = useDesktopChromeStyle(); + const desktopChrome = + chromeFromContext ?? + (isDesktopShell() ? resolveDesktopChromeStyle() : null); const panelRef = useRef(null); const prevModeRef = useRef(mode); const [popupPos, setPopupPos] = useState<{ x: number; y: number } | null>( @@ -370,6 +382,15 @@ const ChatDockPanelShell: React.FC = ({ [mode, onModeChange, style], ); + const windowControlsSpacerPx = windowControlsEndSpacerPx( + desktopChrome, + mode === "right" || mode === "popup", + DOCK_WINDOW_CONTROLS_PAD_PX, + ); + const toolbarStyle: React.CSSProperties | undefined = popupFullscreen + ? { cursor: "default" } + : undefined; + const togglePopupFullscreen = useCallback(() => { setPopupFullscreen((v) => !v); }, []); @@ -426,7 +447,7 @@ const ChatDockPanelShell: React.FC = ({
{title}
@@ -499,6 +520,14 @@ const ChatDockPanelShell: React.FC = ({
+ {windowControlsSpacerPx > 0 ? ( + + ) : null}
{children} {mode === "popup" && !popupFullscreen && ( diff --git a/dashboard/src/components/BrowserWorkspace/index.tsx b/dashboard/src/components/BrowserWorkspace/index.tsx index fba0a4e3..10c54a7c 100644 --- a/dashboard/src/components/BrowserWorkspace/index.tsx +++ b/dashboard/src/components/BrowserWorkspace/index.tsx @@ -32,7 +32,6 @@ import { } from "../../hooks/useViewportMode"; import { normalizeUrl } from "../../utils/normalizeUrl"; import { viewportModeLabel } from "../../utils/browserViewport"; -import { DEFAULT_BROWSER_PROFILE } from "../../utils/browserProfile"; import { showApiError } from "../../utils/showApiToast"; import BrowserViewer, { type BrowserViewerHandle } from "../BrowserViewer"; import styles from "./index.module.less"; @@ -42,8 +41,7 @@ export type PanelMode = "hidden" | "bottom" | "right" | "popup"; const DEFAULT_URL = "https://cloud.tencent.com"; interface BrowserWorkspaceProps { - /** Conversation/session id used to attach the screencast to the agent's - * Chrome. Falls back to "default" on the backend when absent. */ + /** Harness profile for the current user. Server enforces the same mapping. */ sessionId?: string | null; environment?: DisplayEnvironment; style?: React.CSSProperties; @@ -171,7 +169,7 @@ const BrowserWorkspace: React.FC = ({ } return { session_id: sessionInfo.session_id, - profile_name: DEFAULT_BROWSER_PROFILE, + profile_name: sessionInfo.session_id, conversation_id: sessionInfo.conversation_id, channel_source: sessionInfo.channel_source, state: sessionInfo.state || "idle", diff --git a/dashboard/src/components/CatalogTypeCard/catalogTypeCard.module.less b/dashboard/src/components/CatalogTypeCard/catalogTypeCard.module.less index 747400ba..2198cf3c 100644 --- a/dashboard/src/components/CatalogTypeCard/catalogTypeCard.module.less +++ b/dashboard/src/components/CatalogTypeCard/catalogTypeCard.module.less @@ -74,6 +74,14 @@ flex-shrink: 0; } +.titleCol { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + .title { font-size: 15px; font-weight: 600; @@ -88,6 +96,8 @@ -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + /* Always reserve two lines so `hint` sits at the same offset on every card. */ + min-height: 3.1em; } .hint { diff --git a/dashboard/src/components/CatalogTypeCard/index.tsx b/dashboard/src/components/CatalogTypeCard/index.tsx index 76b0d0dd..cb5c10d1 100644 --- a/dashboard/src/components/CatalogTypeCard/index.tsx +++ b/dashboard/src/components/CatalogTypeCard/index.tsx @@ -7,6 +7,8 @@ export interface CatalogTypeCardProps { description: string; icon: ReactNode; hint?: string; + /** Optional chip shown under the title (e.g. a category label). */ + tag?: ReactNode; configuredBadge?: ReactNode; disabled?: boolean; onClick: () => void; @@ -18,6 +20,7 @@ export const CatalogTypeCard = memo(function CatalogTypeCard({ description, icon, hint, + tag, configuredBadge, disabled = false, onClick, @@ -42,7 +45,10 @@ export const CatalogTypeCard = memo(function CatalogTypeCard({ > {icon} -
{title}
+
+
{title}
+ {tag} +
{description}
diff --git a/dashboard/src/components/NotFoundPage.module.less b/dashboard/src/components/NotFoundPage.module.less new file mode 100644 index 00000000..bcd04325 --- /dev/null +++ b/dashboard/src/components/NotFoundPage.module.less @@ -0,0 +1,37 @@ +.wrap { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 420px; + gap: 8px; +} + +.mascot { + display: block; + margin: 0 auto; +} + +.title { + margin: 8px 0 0; + font-size: 22px; + font-weight: 600; + color: var(--fn-text-primary); + line-height: 1.4; +} + +.hint { + margin: 0 0 8px; + font-size: 14px; + line-height: 1.6; + color: var(--fn-text-secondary); +} diff --git a/dashboard/src/components/NotFoundPage.test.tsx b/dashboard/src/components/NotFoundPage.test.tsx new file mode 100644 index 00000000..09c6167b --- /dev/null +++ b/dashboard/src/components/NotFoundPage.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import NotFoundPage from "./NotFoundPage"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe("NotFoundPage", () => { + it("shows a gentle missing-page message and a path back to chat", () => { + render( + + + , + ); + + expect(screen.getByText("common.notFound")).toBeInTheDocument(); + expect(screen.getByText("common.notFoundHint")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "common.backToChat" }), + ).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/NotFoundPage.tsx b/dashboard/src/components/NotFoundPage.tsx new file mode 100644 index 00000000..bc7087ff --- /dev/null +++ b/dashboard/src/components/NotFoundPage.tsx @@ -0,0 +1,23 @@ +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { OctopEmptyMascot } from "./EmptyState"; +import styles from "./NotFoundPage.module.less"; + +/** Catch-all placeholder for dashboard paths that do not match any route. */ +export default function NotFoundPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + return ( +
+
+ +

{t("common.notFound")}

+

{t("common.notFoundHint")}

+ +
+
+ ); +} diff --git a/dashboard/src/components/OctopSpinner/OctopSpinner.module.less b/dashboard/src/components/OctopSpinner/OctopSpinner.module.less new file mode 100644 index 00000000..e334a003 --- /dev/null +++ b/dashboard/src/components/OctopSpinner/OctopSpinner.module.less @@ -0,0 +1,66 @@ +// Sizing vars come from `.octop-spin` in styles/spin-override.css so the +// indicator can follow antd's size prop; fallbacks cover standalone usage. +.host { + position: relative; + display: inline-block; + width: var(--octop-spinner-size, 24px); + height: var(--octop-spinner-size, 24px); + font-size: 0; + vertical-align: middle; +} + +.ring { + position: absolute; + inset: 0; + box-sizing: border-box; + border: var(--octop-spinner-ring-width, 2.5px) solid + color-mix(in srgb, var(--fn-color-brand) 20%, transparent); + border-top-color: var(--fn-color-brand); + border-radius: 50%; + animation: octopSpinnerSpin 0.85s linear infinite; +} + +// Percent box, not `inset`: an absolutely positioned replaced element with +// `width: auto` falls back to its intrinsic size (logo.svg is 512px). +.logo { + position: absolute; + top: 16%; + left: 16%; + display: none; + width: 68%; + height: 68%; + border-radius: 22%; + object-fit: contain; + animation: octopSpinnerBreathe 1.6s ease-in-out infinite; +} + +// Only the large ring is roomy enough for a legible logo; smaller spins sit in +// selects, list rows and inline labels and stay ring-only. +:global(.octop-spin-lg) .logo { + display: block; +} + +@keyframes octopSpinnerSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes octopSpinnerBreathe { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(0.94); + opacity: 0.88; + } +} + +@media (prefers-reduced-motion: reduce) { + .ring, + .logo { + animation: none; + } +} diff --git a/dashboard/src/components/OctopSpinner/index.tsx b/dashboard/src/components/OctopSpinner/index.tsx new file mode 100644 index 00000000..af06ec55 --- /dev/null +++ b/dashboard/src/components/OctopSpinner/index.tsx @@ -0,0 +1,28 @@ +import styles from "./OctopSpinner.module.less"; + +const LOGO = `${import.meta.env.BASE_URL}logo.svg`; + +interface OctopSpinnerProps { + /** antd appends `${prefixCls}-dot` when this is the ConfigProvider indicator. */ + className?: string; +} + +/** + * Loading indicator matching the first-paint boot splash in index.html: + * brand-colored ring around the breathing Octop logo. Wired globally as the + * antd Spin indicator; only `size="large"` shows the logo. + */ +export default function OctopSpinner({ className }: OctopSpinnerProps) { + return ( + + + + + ); +} diff --git a/dashboard/src/components/PwaInstallPrompt/index.test.tsx b/dashboard/src/components/PwaInstallPrompt/index.test.tsx new file mode 100644 index 00000000..23c8d005 --- /dev/null +++ b/dashboard/src/components/PwaInstallPrompt/index.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import PwaInstallPrompt from "./index"; + +describe("PwaInstallPrompt in desktop shell", () => { + afterEach(() => { + delete (window as Window & { _wails?: unknown })._wails; + }); + + it("hides the install button when the Wails bridge is present", () => { + Object.defineProperty(window, "_wails", { + configurable: true, + value: { invoke: () => undefined }, + }); + render(); + expect(screen.queryByLabelText("安装应用")).toBeNull(); + }); + + it("still offers install in a regular browser", () => { + render(); + expect(screen.getByLabelText("安装应用")).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/PwaInstallPrompt/index.tsx b/dashboard/src/components/PwaInstallPrompt/index.tsx index 6daaf522..63eeedb1 100644 --- a/dashboard/src/components/PwaInstallPrompt/index.tsx +++ b/dashboard/src/components/PwaInstallPrompt/index.tsx @@ -8,6 +8,7 @@ import { triggerInstall, waitForInstallPrompt, } from "../../pwa-prompt"; +import { isDesktopShell } from "../../utils/desktopChrome"; import styles from "./index.module.less"; const DISMISSED_KEY = "pwa:install-dismissed"; @@ -165,7 +166,7 @@ export default function PwaInstallPrompt({ () => !!localStorage.getItem(DISMISSED_KEY), ); - if (isStandalone() || installState.installed) return null; + if (isStandalone() || isDesktopShell() || installState.installed) return null; // Chat right float: always expose the install entry until the app is // installed (ignore Header dismiss + beforeinstallprompt lag). Dev has no @@ -262,7 +263,7 @@ export function PwaAutoPrompt() { const [show, setShow] = useState(false); useEffect(() => { - if (!isIosDevice() || isStandalone()) return; + if (!isIosDevice() || isStandalone() || isDesktopShell()) return; if ( localStorage.getItem(DISMISSED_KEY) || localStorage.getItem(IOS_SHOWN_KEY) diff --git a/dashboard/src/components/ResizableTable/ResizableTable.module.css b/dashboard/src/components/ResizableTable/ResizableTable.module.css index e6047454..8f245047 100644 --- a/dashboard/src/components/ResizableTable/ResizableTable.module.css +++ b/dashboard/src/components/ResizableTable/ResizableTable.module.css @@ -14,6 +14,12 @@ z-index: 2; } +/* The last header cell sits on the table edge: an outward handle would extend + the scrollable area and show a few-pixel horizontal scrollbar. */ +.cell:last-child .handle { + inset-inline-end: 0; +} + .handle::after { position: absolute; top: 20%; diff --git a/dashboard/src/context/AgentContext.test.ts b/dashboard/src/context/AgentContext.test.ts index ec489388..c14d3fcc 100644 --- a/dashboard/src/context/AgentContext.test.ts +++ b/dashboard/src/context/AgentContext.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OctopAgent } from "./AgentContext"; -import { - projectChatAgentOption, - selectEnabledExperts, -} from "./AgentContext"; +import { projectChatAgentOption, selectEnabledExperts } from "./AgentContext"; function agent( agent_id: string, @@ -66,9 +63,7 @@ describe("selectEnabledExperts", () => { ]; // S1 is the resolvedAgentId (URL is /chat/S1). Without pinActive it must // disappear from the sidebar just like every other stopped expert. - const sidebar = selectEnabledExperts(agents, "S1").map( - (a) => a.agent_id, - ); + const sidebar = selectEnabledExperts(agents, "S1").map((a) => a.agent_id); expect(sidebar).toEqual(["G1"]); // @-picker / minimal-layout path also excludes it. const pickable = selectEnabledExperts(agents, "S1", { @@ -83,10 +78,7 @@ describe("selectEnabledExperts", () => { }); it("returns empty when nothing is running and there is no active pin", () => { - const agents = [ - agent("A", "stopped"), - agent("B", "failed"), - ]; + const agents = [agent("A", "stopped"), agent("B", "failed")]; expect(selectEnabledExperts(agents, null)).toEqual([]); }); diff --git a/dashboard/src/context/BackupOperationContext.tsx b/dashboard/src/context/BackupOperationContext.tsx index b6906767..08e1a0c3 100644 --- a/dashboard/src/context/BackupOperationContext.tsx +++ b/dashboard/src/context/BackupOperationContext.tsx @@ -14,6 +14,7 @@ import { backupApi, type BackupOperationKind, type BackupStatusResponse, + type CreateBackupOptions, } from "../api/modules/backup"; import { apiErrorMessage } from "../utils/apiError"; @@ -28,7 +29,7 @@ interface BackupOperationContextValue { creating: boolean; restoring: boolean; autoRunning: boolean; - createBackup: () => Promise; + createBackup: (options: CreateBackupOptions) => Promise; runAutoBackup: () => Promise; restoreBackup: (name: string, restoreConfig: boolean) => Promise; uploadBackup: (file: File) => Promise; @@ -95,22 +96,25 @@ export function BackupOperationProvider({ children }: { children: ReactNode }) { return true; }, []); - const createBackup = useCallback(async () => { - if (!beginLocal("create")) return false; - try { - await backupApi.createBackup(); - message.success(t("backup.createSuccess")); - notifySettled(); - return true; - } catch (err: unknown) { - message.error(apiErrorMessage(err, t("backup.createFailed"), t)); - return false; - } finally { - localOwnedRef.current = false; - setKind(null); - void syncFromServer(); - } - }, [beginLocal, message, notifySettled, syncFromServer, t]); + const createBackup = useCallback( + async (options: CreateBackupOptions) => { + if (!beginLocal("create")) return false; + try { + await backupApi.createBackup(options); + message.success(t("backup.createSuccess")); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.createFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + void syncFromServer(); + } + }, + [beginLocal, message, notifySettled, syncFromServer, t], + ); const runAutoBackup = useCallback(async () => { if (!beginLocal("auto")) return false; diff --git a/dashboard/src/hooks/useBrowserSessionState.ts b/dashboard/src/hooks/useBrowserSessionState.ts index 4a579aee..3cce37a7 100644 --- a/dashboard/src/hooks/useBrowserSessionState.ts +++ b/dashboard/src/hooks/useBrowserSessionState.ts @@ -3,7 +3,8 @@ import api from "../api"; import { getWsUrl } from "../api/config"; import { getAuthToken } from "../api/request"; import type { BrowserSession, DisplayEnvironment } from "../api/types/browser"; -import { DEFAULT_BROWSER_PROFILE } from "../utils/browserProfile"; +import { resolveBrowserProfile } from "../utils/browserProfile"; +import { useCurrentUser } from "./useCurrentUser"; /** * Session update event pushed over WebSocket from the backend. @@ -57,6 +58,9 @@ export function useBrowserSessionState( conversationId?: string, enabled = true, ): BrowserSessionState { + const currentUser = useCurrentUser(); + const browserProfile = resolveBrowserProfile(currentUser?.id); + const active = enabled && browserProfile != null; const [session, setSession] = useState(null); const [environment, setEnvironment] = useState("headless-server"); @@ -73,7 +77,7 @@ export function useBrowserSessionState( const fetchSessions = useCallback(async () => { try { - const resp = await api.getSessions(conversationIdRef.current); + const resp = await api.getSessions(); if (unmountedRef.current) return; if (resp.ok) { setEnvironment(resp.environment); @@ -104,7 +108,7 @@ export function useBrowserSessionState( }, []); const connectWs = useCallback(() => { - if (unmountedRef.current) return; + if (unmountedRef.current || !browserProfile) return; // Clean up any previous connection if (wsRef.current) { @@ -149,7 +153,7 @@ export function useBrowserSessionState( width: 1, height: 1, reuse_session: true, - session_id: DEFAULT_BROWSER_PROFILE, + session_id: browserProfile, }), ); } catch { @@ -168,12 +172,11 @@ export function useBrowserSessionState( type: string; }; if (msg.type === "session_update") { - // Shared default profile applies across conversations; also accept - // updates scoped to the current conversation id when present. + // Browser state is scoped to the authenticated user's profile. const cid = conversationIdRef.current; const shared = - msg.session_id === DEFAULT_BROWSER_PROFILE || - msg.conversation_id === DEFAULT_BROWSER_PROFILE; + msg.session_id === browserProfile || + msg.conversation_id === browserProfile; if (!cid || shared || msg.conversation_id === cid) { setSession((prev) => { if (!prev) { @@ -182,7 +185,7 @@ export function useBrowserSessionState( // HTTP fetch to complete. return { session_id: msg.session_id, - profile_name: DEFAULT_BROWSER_PROFILE, + profile_name: browserProfile, conversation_id: msg.conversation_id, channel_source: msg.channel_source, state: msg.state, @@ -230,7 +233,7 @@ export function useBrowserSessionState( }; wsRef.current = ws; - }, [fetchSessions, scheduleReconnect]); + }, [browserProfile, fetchSessions, scheduleReconnect]); useEffect(() => { connectWsRef.current = connectWs; @@ -240,7 +243,7 @@ export function useBrowserSessionState( // Initial HTTP fetch + WS connect useEffect(() => { - if (!enabled) return; + if (!active) return; unmountedRef.current = false; fetchSessions(); connectWs(); @@ -260,13 +263,13 @@ export function useBrowserSessionState( wsRef.current = null; } }; - }, [enabled, fetchSessions, connectWs]); + }, [active, fetchSessions, connectWs]); // Re-fetch when conversationId changes useEffect(() => { - if (!enabled) return; + if (!active) return; fetchSessions(); - }, [enabled, conversationId, fetchSessions]); + }, [active, conversationId, fetchSessions]); return { session, diff --git a/dashboard/src/hooks/useBrowserStream.ts b/dashboard/src/hooks/useBrowserStream.ts index 1c33782c..773c0453 100644 --- a/dashboard/src/hooks/useBrowserStream.ts +++ b/dashboard/src/hooks/useBrowserStream.ts @@ -29,11 +29,7 @@ interface BrowserStreamCallbacks { } interface ConnectOptions { - /** Optional harness profile name. All surfaces (chat panel, standalone - * page, and the agent's `browser_use` tool) share the same `"default"` - * profile so cookies/login and open tabs stay consistent across - * conversations and between headed and headless runs. When unset, the - * backend falls back to `"default"` as well. */ + /** Harness profile for the current user. The WebSocket also binds from JWT. */ sessionId?: string | null; } @@ -119,8 +115,6 @@ export function useBrowserStream() { }; if (options.sessionId) { startMsg.session_id = options.sessionId; - } else { - startMsg.session_id = "default"; } ws.send(JSON.stringify(startMsg)); setCurrentUrl(url); diff --git a/dashboard/src/hooks/useDesktopChrome.ts b/dashboard/src/hooks/useDesktopChrome.ts index c71b572f..6b3f0e8b 100644 --- a/dashboard/src/hooks/useDesktopChrome.ts +++ b/dashboard/src/hooks/useDesktopChrome.ts @@ -1,4 +1,11 @@ -import { useEffect, useState } from "react"; +import { + createContext, + createElement, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; import { applyDesktopChrome, installDesktopWindowDrag, @@ -7,6 +14,8 @@ import { type DesktopChromeStyle, } from "../utils/desktopChrome"; +const DesktopChromeContext = createContext(null); + /** Activate frameless window chrome after the Wails bridge appears. */ export function useDesktopChrome(): DesktopChromeStyle | null { const [style, setStyle] = useState(null); @@ -24,7 +33,6 @@ export function useDesktopChrome(): DesktopChromeStyle | null { if (tryApply()) { return () => { cancelled = true; - applyDesktopChrome(null); }; } const timer = window.setInterval(() => { @@ -35,9 +43,22 @@ export function useDesktopChrome(): DesktopChromeStyle | null { cancelled = true; window.clearInterval(timer); window.clearTimeout(stop); - applyDesktopChrome(null); }; }, []); return style; } + +export function DesktopChromeProvider({ + value, + children, +}: { + value: DesktopChromeStyle | null; + children: ReactNode; +}) { + return createElement(DesktopChromeContext.Provider, { value }, children); +} + +export function useDesktopChromeStyle(): DesktopChromeStyle | null { + return useContext(DesktopChromeContext); +} diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx index 445f6aa3..838de714 100644 --- a/dashboard/src/layouts/sidebarNav.tsx +++ b/dashboard/src/layouts/sidebarNav.tsx @@ -144,7 +144,6 @@ export function buildNavSections( path: "/knowledge-bases", icon: , labelKey: "nav.knowledgeBases", - badge: "BETA", }); } if (settingsItems.length > 0) { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 7846a35a..3aafe548 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -52,6 +52,8 @@ "unknownError": "Unknown error", "noPermission": "Permission denied", "noPermissionHint": "You do not have access to this page. Ask an administrator to grant it.", + "notFound": "Page not found", + "notFoundHint": "This address does not match any page. Check the link, or go back to chat.", "backToChat": "Back to chat", "viewDetail": "View Details", "viewMore": "View More", @@ -75,6 +77,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", + "BACKUP_SCHEMA_INCOMPATIBLE": "This backup uses database schema version {{archive_schema_version}}, which cannot be restored by this Octop version (maximum supported: {{runtime_schema_version}}).", "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", @@ -101,10 +104,12 @@ "PROVIDER_LOCAL_PROTECTED": "Local runtime providers cannot be deleted.", "STORAGE_BACKEND_NAME_TAKEN": "Storage backend name is already in use.", "STORAGE_BACKEND_REFERENCED": "Storage backend is in use and cannot be removed.", + "STORAGE_BACKEND_DEPS_FAILED": "Could not install optional components for this storage backend. Check network access and retry.", "CHANNEL_KIND_UNSUPPORTED": "Unsupported channel type.", "CHANNEL_INVALID_CREDENTIALS": "Invalid channel credentials.", "CHANNEL_NAME_TAKEN": "This channel name is already in use. Please choose another.", "CONNECTOR_NOT_FOUND": "Connector not found.", + "CONNECTOR_NAME_TAKEN": "This connector name is already in use. Please choose another.", "CONNECTOR_INVALID_CREDENTIALS": "Invalid connector credentials.", "CONNECTOR_OAUTH_HTTPS_REQUIRED": "Octop is currently being accessed over public HTTP, but Notion OAuth callbacks require HTTPS. Use HTTPS, or access Octop locally through localhost or 127.0.0.1.", "CONNECTOR_KIND_UNSUPPORTED": "Unsupported connector type.", @@ -164,7 +169,7 @@ "common": "Common", "control": "Control", "experts": "Experts", - "tasks": "Tasks", + "tasks": "Automation", "connectors": "Connectors", "acp": "ACP", "agents": "Agents", @@ -297,7 +302,7 @@ "emptyGuideStepWhat": "What is a knowledge base?", "emptyGuideStepWhatDetail": "A knowledge base splits and embeds uploaded documents so experts can retrieve relevant passages in chat, instead of stuffing the full text into context.", "emptyGuideStepHow": "How to use it", - "emptyGuideStepHowDetail": "Create a knowledge base and upload md / txt / pdf / docx / pptx files. Turn on Default enabled, or select it manually in chat.", + "emptyGuideStepHowDetail": "Create a knowledge base and upload text (md / txt / rst / html / json / yaml, …), spreadsheets (csv / tsv / xls / xlsx / xlsm), or pdf / docx / pptx. Turn on Default enabled, or select it manually in chat.", "emptyGuideStepShare": "What sharing does", "emptyGuideStepShareDetail": "When shared, everyone signed in on this instance can read and select it; only the owner can edit. Default enabled applies only to the owner.", "enableGuideTitle": "How do I enable knowledge bases?", @@ -332,7 +337,8 @@ "maxDocumentsHint": "Maximum number of files in this knowledge base. 0 = unlimited, default 100.", "documentTooLarge": "Each document must be at most {{sizeMb}} MB.", "baseLimitReached": "You can create at most {{count}} knowledge bases.", - "uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.", + "uploadHint": "Supports text (md / txt / rst / html / json / yaml, …), spreadsheets (csv / tsv / xls / xlsx / xlsm), and pdf / docx / pptx. Max {{sizeMb}} MB per file.", + "uploadHintOcr": "Also supports png / jpg / webp images and OCR fallback for scanned PDFs. Max {{sizeMb}} MB per file.", "viewCard": "Cards", "viewTable": "Table", "updatedAt": "Updated", @@ -384,6 +390,8 @@ "pathBreadcrumb": "Path", "deleteFolderConfirm": "Delete this folder and everything inside it?", "uploaded": "Documents uploaded", + "uploading": "Uploading {{name}}", + "uploadingMany": "Uploading {{name}} ({{index}}/{{total}})", "uploadFailed": "Failed to upload documents", "emptyDocuments": "No documents yet", "filename": "File name", @@ -420,7 +428,7 @@ "localOnnx": "Local ONNX", "remoteEmbedding": "Online embedding", "rebuildConfirmTitle": "Rebuild all knowledge indexes?", - "rebuildConfirmDescription": "Changing the embedding model clears and re-embeds every knowledge index.", + "rebuildConfirmDescription": "Changing the embedding model or OCR settings rebuilds every knowledge index.", "notDownloaded": "not downloaded", "checkRuntime": "Runtime", "checkInstalled": "installed", @@ -439,6 +447,15 @@ "downloadModel": "Download", "downloadNeedModel": "The selected model is not downloaded yet. Download it before saving.", "onnxServiceEnabled": "Local ONNX service enabled", + "ocrTitle": "Image and scanned-document OCR", + "ocrDescription": "Optional. Indexes images and automatically recognizes scanned PDFs when normal text extraction returns nothing.", + "ocrLocal": "Local ONNX", + "ocrRemote": "Online vision model", + "ocrLocalHint": "RapidOCR components and default Chinese/English models are installed when saved.", + "ocrRemotePrivacy": "Images and rendered scan pages are sent to the selected online model provider.", + "ocrSelectModel": "Select an image-capable model", + "ocrNoProviders": "No online provider has an image-capable model", + "ocrNoModels": "This provider has no image-capable model", "enable": "Enable", "featureEnabled": "Knowledge bases enabled", "featureDisabled": "Knowledge bases disabled", @@ -1047,6 +1064,13 @@ "generating": "Generating", "generatingWithElapsed": "Generating · {{seconds}}s", "scrollToBottom": "Back to bottom", + "turnTimeline": { + "label": "Conversation turns", + "jumpToQuery": "Jump to message {{index}}", + "userFallback": "(empty message)", + "emptyAssistant": "(no reply yet)", + "runningAssistant": "(generating…)" + }, "loadingEarlierMessages": "Loading earlier messages…", "scrollForEarlierMessages": "Scroll up for earlier messages", "refreshingMessages": "Refreshing messages…", @@ -1158,12 +1182,96 @@ "emptyResult": "Model returned an empty result" }, "hitl": { - "title": "Tool approval required", + "title": "Confirm this action", "approve": "Approve", "reject": "Reject", "approved": "Approved", "rejectedLabel": "Rejected", - "rejected": "Rejected by user" + "rejected": "Rejected by user", + "args": { + "action": "Action", + "level": "Detail", + "url": "URL", + "command": "Command", + "cmd": "Command", + "path": "Path", + "file": "File", + "file_path": "File path", + "query": "Query", + "pattern": "Pattern", + "content": "Content", + "text": "Text", + "selector": "Selector", + "ref": "Element", + "key": "Key", + "value": "Value", + "direction": "Direction", + "amount": "Amount", + "prompt": "Prompt", + "old_string": "Original", + "new_string": "Replacement", + "timeout": "Timeout", + "method": "Method", + "body": "Body", + "code": "Script", + "wait": "Wait", + "element_ref": "Element", + "full_page": "Full page", + "tab_id": "Tab" + }, + "bool": { + "true": "Yes", + "false": "No" + }, + "browser": { + "actions": { + "navigate": "Open a page", + "open": "Open a page", + "snapshot": "Capture a page snapshot", + "dom_tree": "Read the page structure", + "screenshot": "Take a screenshot", + "click": "Click a page element", + "type": "Type into the page", + "fill": "Fill a form field", + "press": "Press a key", + "wait": "Wait for the page", + "select": "Choose a dropdown option", + "scroll": "Scroll the page", + "hover": "Hover over an element", + "eval_js": "Run a script on the page", + "go_back": "Go back", + "go_forward": "Go forward", + "reload": "Reload the page", + "new_tab": "Open a new tab", + "switch_tab": "Switch tabs", + "close_tab": "Close a tab", + "list_tabs": "List tabs", + "close_session": "Close the browser session" + }, + "levels": { + "minimal": "Minimal structure", + "interactive": "Interactive elements only", + "full": "Full structure", + "structured": "Structured content" + }, + "directions": { + "up": "Up", + "down": "Down", + "left": "Left", + "right": "Right" + } + }, + "summaries": { + "domTreeInteractive": "Read the interactive structure of the current page", + "domTree": "Read the structure of the current page", + "screenshot": "Take a screenshot of the current page", + "openUrl": "Open {{url}}", + "runCommand": "Run command: {{command}}", + "writeFile": "Write file {{path}}", + "readFile": "Read file {{path}}", + "editFile": "Edit file {{path}}", + "fetchUrl": "Fetch {{url}}" + } }, "ask": { "title": "A few questions first", @@ -1176,6 +1284,8 @@ "submit": "Send answers", "skip": "You decide", "skipMessage": "You decide — pick the best default and tell me what you assumed.", + "dismiss": "Close", + "dismissMessage": "The user closed these questions without answering. Do not ask again — wrap up this turn.", "answered": "Answered", "answeredSummary": "Answered {{count}} question(s)", "dismissedSummary": "Closed {{count}} question(s)", @@ -1185,6 +1295,7 @@ "openBrowser": "View browser", "openBrowserHint": "Agent is browsing the web", "openTerminal": "Open terminal", + "openTrajectory": "Trajectory", "openPhone": "Open remote phone", "openWorkspace": "Workspace", "editFileCard": "Edited {{count}} files", @@ -1200,6 +1311,57 @@ "dockFileMaybeDeleted": "This file may have been a temporary artifact during processing and has already been deleted.", "remoteBrowserTitle": "Remote Browser", "dockTerminalTitle": "Terminal", + "trajectoryTitle": "Trajectory", + "trajectoryEmpty": "No trajectory events yet", + "trajectorySelectSession": "Select a session to view trajectory", + "trajectoryLoadError": "Failed to load trajectory", + "trajectoryLoadEarlier": "Load earlier history", + "trajectoryLaneInput": "Input", + "trajectoryLaneModel": "Model", + "trajectoryLaneTools": "Tools", + "trajectoryTimeline": "Trajectory timeline", + "trajectoryMetrics": "Session metrics", + "trajectoryExport": "Export", + "trajectoryExportFailed": "Failed to export trajectory", + "trajectoryMetricTurns": "Turns", + "trajectoryMetricSteps": "Steps", + "trajectoryMetricLlmMs": "LLM", + "trajectoryMetricToolMs": "Tools", + "trajectoryMetricTtft": "TTFT", + "trajectoryMetricTokPerS": "tok/s", + "trajectoryMetricCacheHit": "Cache", + "trajectoryMetricInputTokens": "In", + "trajectoryMetricOutputTokens": "Out", + "trajectoryMetricCacheRead": "Cache read", + "trajectoryDetailLoading": "Loading detail…", + "trajectoryDetailError": "Failed to load event detail", + "trajectoryToolbarDuration": "Duration", + "trajectoryToolbarTurns": "Turns", + "trajectoryToolbarCalls": "Calls", + "trajectoryToolbarSearch": "Search trajectory", + "trajectoryToolCallOnly": "(tool call only)", + "trajectoryCollapsedToolCallOne": "{{count}} tool call · {{names}}", + "trajectoryCollapsedToolCallMany": "{{count}} tool calls · {{names}}", + "trajectoryCollapsedTurn": "{{steps}} steps · {{toolCalls}} tool calls", + "trajectoryInspectorKind": "Kind", + "trajectoryInspectorSource": "Source", + "trajectoryInspectorStatus": "Status", + "trajectoryInspectorError": "Error", + "trajectoryInspectorCompleted": "Completed", + "trajectoryInspectorTtft": "TTFT", + "trajectoryInspectorTokens": "Tokens", + "trajectoryInspectorTiming": "Request Timing", + "trajectoryInspectorStarted": "Started", + "trajectoryInspectorTotalDuration": "Total duration", + "trajectoryInspectorGeneration": "Generation", + "trajectoryInspectorThroughput": "Throughput", + "trajectoryInspectorPlaceholder": "Select a record", + "trajectoryInspectorSummary": "Summary", + "trajectoryInspectorPreview": "Preview", + "trajectoryInspectorResult": "Result", + "trajectoryInspectorPayload": "Payload", + "trajectoryInspectorEmptyPayload": "No payload", + "trajectoryInspectorRaw": "Raw", "dockPhoneTitle": "Remote Phone", "dockToolUiTitle": "Plugin tool", "dockToolUiMissing": "Tool result is no longer available in this conversation.", @@ -1259,7 +1421,7 @@ }, "toolSettings": { "title": "Tool settings", - "hint": "Turn off tools you do not want the model to call. Required system tools cannot be disabled. Plugin tools need the plugin enabled globally; you can also toggle them per agent under Plugins.", + "hint": "Turn off built-in tools you do not want the model to call. Required system tools cannot be disabled.", "empty": "No tools available", "loadFailed": "Failed to load tool settings", "saveSuccess": "Tool settings saved", @@ -1378,6 +1540,16 @@ "storedTitle": "Backup files", "storedDesc": "System backups are stored in the directory below. Download or restore with one click.", "createButton": "Create backup", + "createModalTitle": "Choose backup contents", + "createModalDesc": "Core database data is always included. Select any additional content you need.", + "includeDatabase": "Core database data (required)", + "includeConfig": "Configuration (config.json and env)", + "includeWorkspaces": "Agent workspaces", + "includeSkillPackages": "Global skill packages (skill-packages)", + "includePlugins": "Installed plugins (plugins)", + "includeKnowledge": "Knowledge base files (knowledge)", + "includeChats": "Chat history (including trajectories)", + "includeChatsHint": "Chat history and trajectories can be large and are excluded by default.", "creating": "Backing up…", "uploadButton": "Upload backup", "uploadSuccess": "Saved {{name}}", @@ -1390,6 +1562,7 @@ "colSize": "Size", "colCreated": "Created", "colModified": "Modified", + "colContents": "Contents", "colActions": "Actions", "restoreAction": "Restore", "deleteConfirmTitle": "Delete backup", @@ -1405,15 +1578,27 @@ "importButton": "Choose backup file", "uploading": "Uploading… {{percent}}%", "restoring": "Restoring…", - "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. While restore runs, database-backed APIs may be briefly unavailable — prefer a maintenance window. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", + "importWarning": "Restore only overwrites content present in the backup; current chat history is kept when chats are absent. Database-backed APIs may be briefly unavailable — prefer a maintenance window. If config/env is restored, restart the service manually for it to fully apply. This cannot be undone.", "importConfirmTitle": "Confirm restore", "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten, and the service may be briefly unavailable while the database is replaced. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", "importConfirmOk": "Restore", "importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)", "importFailed": "Failed to restore backup", "restoreConfig": "Also restore config.json and env (requires a manual service restart to apply)", + "tagConfig": "Config", + "tagWorkspaces": "Workspaces", + "tagSkillPackages": "Skill packages", + "tagPlugins": "Plugins", + "tagKnowledge": "Knowledge", + "tagChatsYes": "Conversations and trajectories", + "restoreKeepChats": "This backup has no chat history or trajectories. Current data will be kept.", + "restoreNoConfig": "This backup has no config files, so config/env cannot be restored.", + "restoreNoWorkspaces": "This backup has no agent workspaces, so existing workspace files on disk are not replaced wholesale.", + "restoreNoSkillPackages": "This backup has no global skill packages, so the current skill-packages directory is kept.", + "restoreNoPlugins": "This backup has no plugins directory, so installed plugins are kept.", + "restoreNoKnowledge": "This backup has no knowledge files, so the current knowledge directory is kept.", "autoTitle": "Automatic backup", - "autoDesc": "Schedule full system backups into the backups directory. Only automatic archives are pruned by retention.", + "autoDesc": "Schedule system backups into the backups directory. Chat history is excluded by default. Only automatic archives are pruned by retention.", "autoEnabled": "Enable automatic backup", "autoSchedule": "Schedule", "autoScheduleDaily": "Daily at 04:00", @@ -1423,6 +1608,7 @@ "autoScheduleHint": "cron:m h dom mon dow (server timezone); interval:, e.g. interval:43200 means every 12 hours.", "autoIntervalPreview": "Current: every {{seconds}} seconds (about {{hours}} h)", "autoRetention": "Keep last N automatic backups", + "autoContent": "Backup contents", "autoSave": "Save settings", "autoSaveSuccess": "Automatic backup settings saved", "autoSaveFailed": "Failed to save automatic backup settings", @@ -1664,6 +1850,7 @@ "idTooltip": "A unique identifier for this job, auto-generated by the system.", "name": "Job Name", "nameTooltip": "A descriptive name to easily identify this job in the list.", + "nameTooLong": "Job name must be at most {{max}} characters", "enabled": "Enable Job", "enabledTooltip": "When enabled, the job runs automatically on schedule. Disable to pause execution.", "sectionSchedule": "Execution Frequency", @@ -1785,7 +1972,7 @@ "runtimeTimeoutSeconds": "Timeout (s)", "runtimeMisfireGrace": "Misfire Grace (s)" }, - "noAgentSelected": "Pick an agent in the top-right switcher first to manage its scheduled tasks" + "noAgentSelected": "Pick an expert in the top-right switcher first to manage its scheduled tasks" }, "channels": { "title": "Channels", @@ -1864,7 +2051,7 @@ "displaySettings": "Message display", "responseMode": "Response mode", "responseModeDesc": "Final response hides progress narration before tool calls; live progress keeps the current staged messages", - "responseModeInvoke": "Final only (Recommended)", + "responseModeInvoke": "Final only", "responseModeStream": "Live progress", "showToolHints": "Show tool hints", "showToolHintsDesc": "When enabled, channel messages show tool-call activity and status hints", @@ -1886,27 +2073,27 @@ "checkRequestFailed": "Connection check request failed, please try again later", "checkFailedAutoDisabled": "Credential verification failed, channel has been auto-disabled", "enableFailedCheckFailed": "Enable failed: connection check did not pass", - "quickConfig": "Scan to Connect", - "manualConfig": "Manual Config", + "quickConfig": "Quick setup (scan)", + "manualConfig": "Fill in manually", "quickConfigStep1": "Click the button below to go to WeCom Open Platform", "quickConfigStep2": "Follow the instructions to authorize your bot", "quickConfigStep3": "Once authorized, return here and the channel will connect automatically", "goToAuthorize": "Go to Authorize", "qrLoading": "Generating QR code...", - "qrStep1": "Open WeCom App", - "qrStep2": "Scan the QR code below to authorize", - "qrStep3": "Credentials will be auto-filled after scanning", - "qrScanHint": "Scan with WeCom App", - "weixinQrStep1": "Open WeChat App", - "weixinQrStep2": "Scan the QR code below to authorize", - "weixinQrStep3": "Credentials will be auto-filled after scanning", - "weixinQrScanHint": "Scan with WeChat App", - "dingtalkQrLoading": "Initializing DingTalk application registration...", + "qrStep1": "Open WeCom", + "qrStep2": "Scan to register an AI bot", + "qrStep3": "Confirm binding", + "qrScanHint": "Next step starts automatically after scanning", + "weixinQrStep1": "Open WeChat", + "weixinQrStep2": "Scan to sign in", + "weixinQrStep3": "Confirm on phone", + "weixinQrScanHint": "Scan with WeChat to sign in to your personal account", + "dingtalkQrLoading": "Generating QR code...", "dingtalkQrIntro1": "Generate a DingTalk application authorization QR code", "dingtalkQrIntro2": "Scan and confirm in DingTalk while signed in to the same organization", "dingtalkQrIntro3": "Octop creates and enables the DingTalk channel automatically", - "dingtalkQrStep1": "Open DingTalk App", - "dingtalkQrStep2": "Scan the application authorization code", + "dingtalkQrStep1": "Open DingTalk", + "dingtalkQrStep2": "Scan QR code", "dingtalkQrStep3": "Confirm application creation", "dingtalkQrScanHint": "Scan with DingTalk App to finish authorization", "dingtalkUserCode": "User code", @@ -1947,21 +2134,31 @@ "qrTimeout": "Scan timed out (3 min), please retry", "qrFailed": "Scan failed, please retry", "qrGenerateFailed": "Failed to generate QR code, check network and retry", - "qrRetry": "Regenerate QR Code", - "feishuCreating": "Initializing Feishu bot creation...", + "qrRetry": "Regenerate", + "feishuCreating": "Generating QR code...", "feishuApiTimeout": "Feishu API timed out, please use manual configuration", "feishuSwitchManual": "Use Manual Config", - "feishuQrStep1": "Open Feishu App, tap \"+ → Scan\" in top-right", - "feishuQrStep2": "Scan the QR code below to login to Feishu Open Platform", - "feishuQrScanHint": "Use the built-in Scanner in Feishu App to scan", + "feishuQrStep1": "Open Feishu Scan", + "feishuQrStep2": "Scan QR code", + "feishuQrStep3": "Confirm on phone", + "feishuQrScanHint": "Scan with Feishu App, then confirm to receive credentials", + "feishuQrIntro1": "Click the button below to generate a Feishu AI bot registration QR code", + "feishuQrIntro2": "Scan with Feishu App to finish bot registration", + "feishuQrIntro3": "Credentials are filled in and saved automatically", + "feishuCreateButton": "Create Feishu bot", + "feishuCreateFailed": "Failed to create Feishu bot", "feishuBindSuccess": "Feishu bot created and enabled", "feishuCreateSuccess": "Feishu bot created successfully!", + "feishuCreateSuccessNamed": "Feishu bot \"{{name}}\" created successfully", "feishuBotName": "Bot Name", "feishuManageBot": "Manage on Feishu Open Platform", - "yuanbaoCreating": "Initializing YuanBao bot binding...", - "yuanbaoQrStep1": "Open Tencent YuanBao App", - "yuanbaoQrStep2": "Scan the QR code below to bind your bot", - "yuanbaoQrScanHint": "Scan with Tencent YuanBao App", + "yuanbaoCreating": "Generating QR code...", + "yuanbaoQrStep1": "Open YuanBao", + "yuanbaoQrStep2": "Scan to bind", + "yuanbaoQrStep3": "Confirm authorization", + "yuanbaoQrScanHint": "Confirm the binding in YuanBao after scanning", + "yuanbaoStarting": "Starting YuanBao scan-to-bind...", + "yuanbaoBindFailed": "YuanBao binding failed", "yuanbaoBindSuccess": "YuanBao bot bound and enabled", "yuanbaoCreateSuccess": "YuanBao bot bound successfully!", "dmPolicy": "DM Policy", @@ -1986,6 +2183,16 @@ "qrRetryBtn": "Retry", "wecomGenerateQr": "Generate WeCom scan code", "weixinGenerateQr": "Generate WeChat login QR code", + "wecomQrSuccess": "WeCom bound successfully", + "weixinQrSuccess": "WeChat bound successfully", + "weixinAccountId": "Account ID: {{id}}", + "fieldRequired": "{{label}} is required", + "fieldMustBeJsonObject": "{{label}} must be a JSON object", + "jsonMustBeObject": "Must be a JSON object", + "invalidJson": "Invalid JSON", + "rawConfigTooltip": "Channel-specific config — see harness-gateway docs", + "getCredentials": "Get credentials", + "channelSettingsNamed": "{{kind}} channel settings", "deleteConfirmTitle": "Delete channel \"{{name}}\"?" }, "tokenUsage": { @@ -2146,6 +2353,7 @@ "voice": { "loading": "Loading voice settings…", "loadError": "Failed to load voice settings", + "description": "Manage speech-to-text and text-to-speech models, and choose the active services.", "sttSection": "Speech Input (STT)", "ttsSection": "Read Aloud (TTS)", "free": "Free", @@ -2156,7 +2364,12 @@ "activeUpdated": "Active voice provider updated", "activeUpdateFailed": "Failed to update active provider", "configure": "Configure", + "configureTitle": "Configure {{name}}", "saved": "Configuration saved", + "credentialsRequired": "Complete the connection credentials first", + "probe": "Probe", + "probeSuccess": "Voice model probe succeeded", + "probeFailed": "Voice model probe failed", "tencentHint": "Create SecretId / SecretKey in Tencent Cloud console. ASR and TTS include free trial quota.", "openaiHint": "Uses OpenAI Whisper for STT and OpenAI TTS for read-aloud.", "mimoHint": "Xiaomi MiMo ASR/TTS. Choose endpoint and enter the corresponding API key.", @@ -2596,9 +2809,11 @@ "ollamaNotRunning": "Ollama service is not running", "testUseSavedConfig": "Uses saved configuration", "pageTitle": "Models", - "pageSubtitle": "Manage conversational, image-generation, and video-generation model services in one place.", + "pageSubtitle": "Manage conversational, image/video generation, voice, and search engine services in one place.", "chatModelsTab": "Conversation models", "generationModelsTab": "Generation models", + "voiceModelsTab": "Voice models", + "searchModelsTab": "Search engines", "addCustomProvider": "New Provider", "loadingProviders": "Loading...", "noProvidersHint": "No providers yet. Click the top right to create one.", @@ -2711,9 +2926,11 @@ "advancedSettings": { "description": "Manage runtime configuration and environment variables.", "search": { - "desc": "Configure AI-powered web search providers to enable your assistant to search the internet. Each provider requires an API key from the respective service.", + "desc": "Manage web search providers for agents to retrieve from the internet.", "tip": "You can configure or modify search providers here. Changes take effect immediately on the next chat session.", "configure": "Configure", + "configureTitle": "Configure {{name}}", + "probe": "Probe", "sourceBuiltinTitle": "Current search source: built-in search", "sourceBuiltinDesc": "When no third-party search provider is configured, Octop still provides its built-in search service. It requires no API key, but its stability and availability are not guaranteed. It switches automatically once a third-party provider is configured.", "sourceConfiguredTitle": "Current search source: {{name}}", @@ -2803,6 +3020,8 @@ "maxInputLengthPlaceholder": "Enter max input length", "maxInputLengthRequired": "Max input length is required", "maxInputLengthMin": "Max input length must be at least 1000", + "enableTrajectory": "Record trajectory", + "enableTrajectoryTooltip": "When on, this expert's chats are written to the trajectory ledger and can be opened from Chat. On by default.", "saveSuccess": "Configuration saved successfully", "saveFailed": "Failed to save configuration", "loadFailed": "Failed to load configuration", @@ -3285,10 +3504,11 @@ }, "personalization": { "title": "Personalization", - "description": "Configure this agent's skills, tools, subagents, channels, personality, and memory.", + "description": "Configure this agent's skills, tools, plugins, subagents, channels, personality, and memory.", "tabs": { "skills": "Skills", "tools": "Tools", + "plugins": "Plugins", "subagents": "Subagents", "channels": "Channels", "mbti": "MBTI", @@ -3468,9 +3688,9 @@ "launch": "Launch Browser", "connect": "Connect", "stop": "Close browser", - "shutdownTitle": "Close browser process", - "shutdownConfirm": "This stops the local Chrome process and frees memory. Login cookies stay on disk and can be reused next time. Continue?", - "shutdownFailed": "Failed to stop the browser process", + "shutdownTitle": "Close browser", + "shutdownConfirm": "This will close the current browser window. Signed-in sites stay logged in next time you start.", + "shutdownFailed": "Failed to close the browser", "streaming": "Streaming", "connecting": "Connecting", "browserStarted": "Starting", @@ -3491,8 +3711,8 @@ "viewportMode": "Viewport", "checkInstall": "Check Browser", "checkInstallShort": "Check", - "checkInstallTip": "Check if Playwright browser is installed; install if missing", - "browserAlreadyInstalled": "Browser is already installed", + "checkInstallTip": "Check whether a browser is ready; you can install one in one click if it's missing", + "browserAlreadyInstalled": "Browser is ready", "checkFailed": "Failed to check browser status", "installingBrowser": "Installing Browser", "installing": "Starting installation...", @@ -3500,21 +3720,21 @@ "installSuccessHint": "Browser is ready — you can start a session", "installFailed": "Installation failed", "installFailedHint": "Automatic installation failed. Try again. If downloads are slow, set PLAYWRIGHT_DOWNLOAD_HOST to a mirror URL and retry.", - "notInstalled": "Chromium not installed", - "notInstalledHint": "No usable browser found. Use the button below to install the bundled Chromium automatically.", + "notInstalled": "No browser detected", + "notInstalledHint": "Octop needs a browser to open pages, fill forms, and take screenshots for you. Click below to install one automatically.", "install": "Install browser", "installProgress": "Installing…", "installCancelHint": "Install request cancelled. The server may still be installing — refresh status later.", "uninstall": "Uninstall", - "uninstallTitle": "Remove Playwright Chromium", - "uninstallConfirm": "This closes Octop browser sessions and removes Chromium installed via Playwright. Your system Chrome/Chromium is not affected. Continue?", + "uninstallTitle": "Uninstall built-in browser", + "uninstallConfirm": "This will close any open browser windows in Octop and remove the browser Octop installed. Chrome and other browsers already on your computer are not affected.", "uninstalling": "Uninstalling…", - "uninstallSuccess": "Playwright Chromium removed", + "uninstallSuccess": "Built-in browser removed", "uninstallFailed": "Uninstall failed", "playwrightMissing": "The playwright package is missing. Install octop[browser] extras first.", "installLog": "Install log", "installRetry": "Retry install", - "envReady": "Playwright and Chromium are ready", + "envReady": "Octop can open pages, fill forms, and take screenshots for you.", "envProbeFailed": "Failed to probe browser environment", "sharedSessionHint": "Shares the same browser session as Agent in chat", "selectSession": "Select browser session", @@ -3533,10 +3753,10 @@ "startBrowserDesc": "Environment is ready — follow these steps to browse and control remotely", "startBrowserIdleStep1": "Click Launch Browser below to start a session", "startBrowserIdleStep2": "Enter a URL in the address bar, or use bookmarks and the AI assistant", - "setupTitle": "Browser environment setup required", - "setupDesc": "Follow these steps to configure Playwright and Chromium", - "setupStep1": "Click Check to verify Playwright and Chromium availability", - "setupStep2": "If components are missing, install the browser environment from the dialog", + "setupTitle": "A browser is required", + "setupDesc": "Install a browser first to start remote browsing and automated page actions", + "setupStep1": "Click Check to see if a browser is already available", + "setupStep2": "If none is found, install one from the dialog in a single click", "setupStep3": "When installation finishes, click Launch Browser to start a session", "startBrowserDisabled": "Complete browser environment check and installation first", "startBrowserHeaderHint": "Click Launch Browser below to start a session", @@ -3704,7 +3924,9 @@ "active": "Active" }, "browserStatusActive": "Browser: {{owner}}", - "browserStatusIdle": "Browser (click to view or take over)" + "browserStatusIdle": "Browser (click to view or take over)", + "chromeMissingTitle": "No browser detected", + "chromeMissingJumpToInstall": "Go to Workbench → Browser to install?" }, "browserViewer": { "goBack": "Go back", @@ -3902,7 +4124,7 @@ }, "storage": { "pageTitle": "Storage Management", - "pageSubtitle": "Manage storage backends (COS, S3, OSS, Docker sandbox, etc.) for use by agents", + "pageSubtitle": "Manage storage backends (local, object storage, sandboxes, databases) for use by experts", "myStorage": "My Storage", "supportedTypes": "Supported Types", "addBackend": "Add Storage Backend", @@ -3912,7 +4134,15 @@ "clickToConfigure": "Click to configure this backend", "configuredBadge": "Configured", "emptyMyStorage": "No storage backends yet", - "emptyMyStorageHint": "Go to \"Supported Types\" to pick and configure a backend for your agents", + "emptyMyStorageHint": "Go to \"Supported Types\" to pick and configure a backend for your experts", + "emptyGuideTitle": "No storage backends yet", + "emptyGuideDesc": "A storage backend is the workspace an expert uses: a local directory, object store, sandbox, or database. Configure it here, then attach it by name.", + "emptyGuideStepWhat": "What is a storage backend?", + "emptyGuideStepWhatDetail": "A reusable storage profile. When an expert reads files or runs commands, it uses this backend instead of the default host directory.", + "emptyGuideStepHow": "How to add one", + "emptyGuideStepHowDetail": "Open Supported Types, pick local, object storage, sandbox, or database, then save credentials or a path. You can probe connectivity anytime.", + "emptyGuideStepUse": "How experts use it", + "emptyGuideStepUseDetail": "On the expert workspace, select a configured backend by name. Sandbox backends are created when the expert starts and destroyed when it stops.", "totalBackends": "{{count}} backend(s)", "noBackends": "No storage backends yet. Click the button above to add one.", "nameLabel": "Name", @@ -3936,6 +4166,15 @@ "dockerImageLabel": "Image", "dockerRegistryLabel": "Registry", "dockerPulling": "Pulling Docker image…", + "opensandboxInstalling": "Installing OpenSandbox SDK and probing…", + "opensandbox_probe_ok": "OpenSandbox write/read probe succeeded", + "opensandboxDomainLabel": "Domain", + "opensandboxApiKeyLabel": "API Key", + "opensandboxProtocolLabel": "Protocol (http / https)", + "groupLocal": "Local", + "groupObject": "Object storage", + "groupSandbox": "Sandbox", + "groupDatabase": "Database", "docker_image_pulled": "Image pulled and ready", "docker_image_ready": "Image already available locally", "docker_probe_roundtrip_ok": "In-container write/read probe succeeded", @@ -3961,16 +4200,18 @@ "kindShell": "Local Shell", "kindPostgres": "PostgreSQL", "kindDocker": "Docker Sandbox", + "kindOpensandbox": "OpenSandbox", "kindCustom": "Custom (S3-compatible)", - "descCos": "Tencent Cloud Object Storage — ideal for China deployments, supports CDN acceleration", - "descS3": "Amazon S3, the industry-standard object storage; compatible with most cloud providers", - "descOss": "Alibaba Cloud OSS — best for Alibaba Cloud ecosystem applications", - "descObs": "Huawei Cloud OBS — best for Huawei Cloud ecosystem", - "descFilesystem": "Mount a local host directory — ideal for development and single-machine deployments", - "descShell": "Execute commands in a local shell with timeout and environment isolation", - "descPostgres": "Use PostgreSQL tables as a structured storage backend with schema isolation", - "descDocker": "Run code inside Docker via the Docker API (no host workspace bind); container names follow sandbox_scope; deleting an expert does not remove the sandbox", - "descCustom": "Any S3-compatible service (MinIO, Ceph, Cloudflare R2, etc.)", + "descCos": "Tencent Cloud object storage, low latency in China; supports CDN acceleration and multipart uploads.", + "descS3": "Amazon S3, the industry standard; its protocol is supported by most clouds and self-hosted stores.", + "descOss": "Alibaba Cloud OSS — best inside the Alibaba Cloud ecosystem; free intranet traffic and tiering.", + "descObs": "Huawei Cloud OBS — best inside the Huawei Cloud ecosystem; S3-compatible with tiered storage.", + "descFilesystem": "Mount a host directory as the workspace — fastest reads and writes, no network dependency.", + "descShell": "Run commands in a local shell with timeouts and env isolation; experts use the host toolchain.", + "descPostgres": "Use PostgreSQL tables as a structured backend with schema isolation; experts can share one instance.", + "descDocker": "Run code in a local Docker container with no host workspace bind; containers follow sandbox_scope.", + "descOpensandbox": "Remote OpenSandbox: created when the expert starts, destroyed on stop; the SDK installs on enable.", + "descCustom": "Any S3-compatible service such as MinIO, Ceph, or Cloudflare R2; provide your own endpoint and keys.", "dockerEnv": { "drawerTitle": "Local Docker environment", "checking": "Checking Docker…", @@ -4356,8 +4597,23 @@ "loadFailed": "Failed to load connectors", "tabCatalog": "Catalog", "tabBuiltin": "Built-in connectors", + "tabEnabled": "Enabled connectors", "tabCustom": "Custom connectors", "listSummary": "{{total}} connectors available, {{configured}} configured", + "enabledSummary": "{{count}} connector instances enabled", + "emptyGuideTitle": "No enabled connectors yet", + "emptyGuideDesc": "A connector instance stores one set of credentials for an external service so agents can call its tools. Create an instance from a built-in connector.", + "emptyGuideStepWhat": "What is a connector instance?", + "emptyGuideStepWhatDetail": "One instance = one credential plus a display name. The same built-in connector can back several instances, for example two mailboxes on different accounts.", + "emptyGuideStepHow": "How to create one", + "emptyGuideStepHowDetail": "Open Built-in connectors, pick the service you need, fill in the credentials in the Create connector drawer, probe it, and save — it then shows up on this tab.", + "emptyGuideStepShare": "Sharing and default enabled", + "emptyGuideStepShareDetail": "When shared, every signed-in user on this instance can select it, but only the owner can edit it. Default enabled applies to the owner only.", + "emptyGuideBrowseBuiltin": "Browse built-in connectors", + "emptyGuideAddCustom": "Add custom MCP", + "noCredentials": "Missing credentials", + "sharedFrom": "From {{name}}", + "sharedReadonly": "Shared connector — only the owner can manage it", "customMcp": { "introTitle": "MCP server configuration (JSON). Refer to the following format:", "modeVisual": "Visual", @@ -4483,7 +4739,12 @@ "comingSoon": "Coming soon", "addConnection": "Add {{name}}", "displayName": "Display name", - "defaultOpen": "Open by default", + "description": "Description", + "shared": "Share with others", + "sharedHint": "Other users can select a shared connector, but cannot view or change its configuration.", + "sharedBadge": "Shared", + "defaultEnabled": "Enable by default", + "defaultOpen": "Enable by default", "defaultOpenHint": "Applies only to your account. When off, tools are injected only if you select this connector in chat.", "defaultOpenWarning": "When on, tools are included by default in your Dashboard, IM, and Cron jobs with no connector picks (extra tokens). Dashboard can opt out per turn; Cron explicit picks override defaults.", "defaultOpenLockedBadge": "Default on", @@ -4498,8 +4759,9 @@ "createSuccess": "Connector created", "createFailed": "Failed to create", "saveSuccess": "Connector saved", - "configureConnection": "Configure {{name}}", - "editConnection": "Configure {{name}}", + "configureConnection": "Create {{name}} connector", + "createConnection": "Create {{name}} connector", + "editConnection": "Edit {{name}} connector", "configSection": "Connection settings", "secretConfigured": "Configured — leave blank to keep current value", "secretPlaceholder": "Leave blank to keep current value", @@ -4605,18 +4867,23 @@ "uninstallFailed": "Uninstall failed", "uninstallConfirm": "Uninstall plugin {{id}}?", "empty": "No plugins installed", - "adminHint": "Plugins live under ~/.octop/plugins/ on the server. Use the enable switch to load or unload a plugin. Open Details to enable tools for the agent selected in the sidebar.", + "adminHint": "Plugins live under ~/.octop/plugins/ on the server. Use this page to install, reload, globally enable, inspect, or uninstall them.", "guideTitle": "How to build and import a plugin", "guideDevelopTitle": "1. Develop", "guideDevelopBody": "Create a folder with plugin.yaml and an entry Python file (for example main.py). plugin.yaml needs id, version, name, kind (tool / skill / hook), and entry. In setup(ctx): tool → ctx.tool(...); skill → ctx.skills(\"skills\"); hook → ctx.middleware(...). See the repo plugins/ demos for each kind.", "guidePackageTitle": "2. Package", "guidePackageBody": "Zip the plugin so the archive contains exactly one plugin root with plugin.yaml inside it (either at the zip root or in a single top-level folder). Example: zip -r my-plugin.zip my-plugin/", "guideImportTitle": "3. Import", - "guideImportBody": "Host the .zip where Octop can download it over HTTP(S). Prefer a raw file URL. After install, open Details on the plugin card, select an agent in the sidebar, and enable tools. Local demos: octop plugin install ./plugins/demo-toolkit --force", + "guideImportBody": "Host the .zip where Octop can download it over HTTP(S). Prefer a raw file URL. Configure each agent from Personalization → Plugins. Local demos: octop plugin install ./plugins/demo-toolkit --force", "guideExampleTitle": "Minimal plugin.yaml", "guideExampleYaml": "id: my-plugin\nversion: 1.0.0\nname: My Plugin\ndescription: Example tool plugin\nicon: \"🧩\"\nkind: tool\nentry: main.py", "agentHint": "Once a plugin is enabled, its tools are available to all agents by default. You can turn individual tools off per agent. Configure API keys first when required. New chats pick up the latest tool list.", + "agentPluginHint": "Installed plugins are enabled for this expert by default. Turning one off disables all of its tools, skills, and hooks for this expert while preserving tool configuration.", + "agentEnabled": "Enabled for this agent", + "globallyDisabled": "Disabled globally", + "globalDisabledHint": "An administrator disabled this plugin globally, so it is unavailable even when enabled for this agent.", "noAgent": "Select an agent in the sidebar first", + "noPlugins": "No plugins are installed. Ask an administrator to install one.", "noTools": "No plugin tools available. Ask an admin to install tool plugins.", "enablePluginFirst": "Enable this plugin first. Tools are on by default; you can still turn them off per agent.", "detailToolsHint": "On by default. Turning a tool off applies only to the agent selected in the sidebar (same config as Experts → Tools). Takes effect on the next turn — no restart needed.", @@ -4699,7 +4966,7 @@ "subtitle": "Create and manage your AI experts, or pick a scenario template to get started quickly." }, "tasks": { - "title": "Tasks", + "title": "Automation", "subtitle": "Scheduled tasks that run on a cron trigger" }, "connectors": { @@ -4744,7 +5011,7 @@ }, "models": { "title": "Model Management", - "subtitle": "Configure LLM providers and API keys" + "subtitle": "Configure conversation, generation, voice, and search engines" }, "voice": { "title": "Voice Services", @@ -4772,7 +5039,7 @@ }, "adminAdvanced": { "title": "Application Settings", - "subtitle": "Environment variables, search, voice, backup, HTTPS, and updates" + "subtitle": "Environment variables, backup, HTTPS, and updates" }, "security": { "title": "Security", @@ -4780,7 +5047,7 @@ }, "adminPlugins": { "title": "Plugin Management", - "subtitle": "Install and manage server plugins; enable tools for an agent in the details drawer" + "subtitle": "Install and manage server plugins; configure each agent under Personalization → Plugins" }, "adminUpdates": { "title": "Updates", @@ -4817,7 +5084,7 @@ "pickDevice": "Select a device first", "devicePlaceholder": "Select device", "needsInstall": "Container install required", - "needsInstallDesc": "This host uses a container Android backend. One-click install pulls and starts the container (Docker required on the host).", + "needsInstallDesc": "This host uses a container Android backend. One-click install pulls and starts the container; Docker is installed automatically when missing (manual install required if that fails).", "install": "Install container", "installing": "Starting install…", "installProgress": "Installing Android container…", @@ -4825,8 +5092,8 @@ "installFailed": "Container install failed. Check the log and retry.", "installRetry": "Retry install", "installCancelHint": "Install request cancelled. The server may still be installing — refresh status later.", - "installStep1": "Make sure Docker is installed and usable on this host", - "installStep2": "Click Install container to pull and start the Android container", + "installStep1": "Click Install container; Docker is installed automatically when missing", + "installStep2": "Wait for the Android container to be pulled and started", "installStep3": "When install finishes, refresh status, then click Connect", "needsDevice": "No device connected", "needsDeviceDesc": "Start an Android emulator or connect a phone via USB, then refresh.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index cb7bef76..6f0cebf9 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -52,6 +52,8 @@ "unknownError": "未知错误", "noPermission": "没有权限", "noPermissionHint": "你没有访问此页面的权限,请联系管理员开通。", + "notFound": "页面不存在", + "notFoundHint": "这个地址没有对应的页面。检查一下链接,或返回对话继续使用。", "backToChat": "返回聊天", "viewDetail": "查看详情", "viewMore": "查看更多", @@ -75,6 +77,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", + "BACKUP_SCHEMA_INCOMPATIBLE": "该备份使用数据库结构版本 {{archive_schema_version}},当前 Octop 版本无法恢复(最高支持版本:{{runtime_schema_version}})。", "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", @@ -101,10 +104,12 @@ "PROVIDER_LOCAL_PROTECTED": "本地提供商无法删除。", "STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。", "STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。", + "STORAGE_BACKEND_DEPS_FAILED": "无法安装此后端所需的可选组件。请检查网络后重试。", "CHANNEL_KIND_UNSUPPORTED": "不支持的通道类型。", "CHANNEL_INVALID_CREDENTIALS": "通道凭证无效。", "CHANNEL_NAME_TAKEN": "该通道名称已被使用,请换一个名称。", "CONNECTOR_NOT_FOUND": "未找到连接器。", + "CONNECTOR_NAME_TAKEN": "该连接器名称已被使用,请换一个名称。", "CONNECTOR_INVALID_CREDENTIALS": "连接器凭证无效。", "CONNECTOR_OAUTH_HTTPS_REQUIRED": "当前通过公网 HTTP 访问 Octop,Notion 授权回调地址需要 HTTPS。请改用 HTTPS,或通过 localhost、127.0.0.1 在本机访问 Octop。", "CONNECTOR_KIND_UNSUPPORTED": "不支持的连接器类型。", @@ -130,7 +135,7 @@ "SKILL_ALREADY_EXISTS": "技能 {{name}} 已存在,如需覆盖请启用 overwrite。", "SKILL_PACKAGE_NOT_FOUND": "未找到技能包。", "SKILL_PACKAGE_NAME_TAKEN": "技能包 {{name}} 已存在。", - "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "技能包目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录须为 /(POSIX)或 Agent 工作区根目录(Windows 默认)。", + "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "技能包目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录须为 /(POSIX)或 专家的工作区根目录(Windows 默认)。", "PUBLISHED_EXPERT_SLUG_TAKEN": "已存在发布专家 slug:{{slug}}。", "PUBLISHED_EXPERT_ALREADY_EXISTS": "该专家已发布为「{{name}}」,请改为刷新已发布模板。", "OIDC_BAD_REQUEST": "OIDC 请求无效:{{detail}}", @@ -164,7 +169,7 @@ "common": "常用", "control": "控制", "experts": "专家", - "tasks": "任务", + "tasks": "自动化", "connectors": "连接器", "acp": "ACP", "agents": "Agents", @@ -297,7 +302,7 @@ "emptyGuideStepWhat": "什么是知识库?", "emptyGuideStepWhatDetail": "知识库会把上传的文档切分并向量化。对话时专家可以检索引用相关段落,而不必把全文塞进上下文。", "emptyGuideStepHow": "知识库怎么用", - "emptyGuideStepHowDetail": "创建知识库并上传 md / txt / pdf / docx / pptx。可设为默认启用,或在对话中手动勾选。", + "emptyGuideStepHowDetail": "创建知识库并上传文本(md / txt / rst / html / json / yaml 等)、表格(csv / tsv / xls / xlsx / xlsm)、pdf / docx / pptx。可设为默认启用,或在对话中手动勾选。", "emptyGuideStepShare": "共享有什么作用", "emptyGuideStepShareDetail": "共享后实例内登录用户可读、可选用;仅所有者可编辑。默认启用只对创建者生效。", "enableGuideTitle": "怎么开启知识库功能?", @@ -332,7 +337,8 @@ "maxDocumentsHint": "本知识库可容纳的最大文档数,0 表示不限制,默认为 100。", "documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。", "baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。", - "uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。", + "uploadHint": "支持文本(md / txt / rst / html / json / yaml 等)、表格(csv / tsv / xls / xlsx / xlsm)、pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。", + "uploadHintOcr": "另支持 png / jpg / webp 图片及扫描 PDF OCR,单文件不超过 {{sizeMb}} MB。", "viewCard": "卡片", "viewTable": "表格", "updatedAt": "更新时间", @@ -384,6 +390,8 @@ "pathBreadcrumb": "路径导航", "deleteFolderConfirm": "删除此文件夹及其内部文档?", "uploaded": "文档已上传", + "uploading": "正在上传 {{name}}", + "uploadingMany": "正在上传 {{name}}({{index}}/{{total}})", "uploadFailed": "上传文档失败", "emptyDocuments": "暂无文档", "filename": "文件名", @@ -420,7 +428,7 @@ "localOnnx": "本地 ONNX", "remoteEmbedding": "在线 Embedding", "rebuildConfirmTitle": "重建知识库索引?", - "rebuildConfirmDescription": "切换向量模型会清空并重新嵌入所有知识库索引。", + "rebuildConfirmDescription": "更改向量模型或 OCR 设置会重建所有知识库索引。", "notDownloaded": "未下载", "checkRuntime": "运行时组件", "checkInstalled": "已安装", @@ -439,6 +447,15 @@ "downloadModel": "下载", "downloadNeedModel": "所选模型尚未下载,请先下载后再保存。", "onnxServiceEnabled": "已开启本地 ONNX 服务", + "ocrTitle": "图片与扫描件 OCR", + "ocrDescription": "可选能力。启用后可索引图片,并在普通 PDF 无法提取文字时自动识别扫描页。", + "ocrLocal": "本地 ONNX", + "ocrRemote": "在线视觉模型", + "ocrLocalHint": "保存时自动安装 RapidOCR 组件和默认中英文模型。", + "ocrRemotePrivacy": "图片和扫描页会发送给所选在线模型提供商。", + "ocrSelectModel": "选择支持图片输入的模型", + "ocrNoProviders": "暂无支持图片输入的在线模型提供商", + "ocrNoModels": "该提供商没有支持图片输入的模型", "enable": "启用", "featureEnabled": "知识库已启用", "featureDisabled": "知识库已停用", @@ -877,7 +894,7 @@ "localShellDesc": "可读写文件并执行 Shell 指令,权限范围最大。", "localShellWarning": "⚠️ 此模式可访问主机完整环境,请仅在信任的场景下使用。", "filesystem": "本地文件系统(不能执行指令)", - "filesystemDesc": "仅在 Agent 工作区目录内读写文件,不能执行 Shell 指令。", + "filesystemDesc": "仅在专家的工作区目录内读写文件,不能执行 Shell 指令。", "state": "会话状态(不实际操作文件和执行指令)", "stateDesc": "进程内临时状态存储,不读写真实文件、不执行指令,重启后数据不保留。", "composite": "组合模式(可以通过路径组合多种模式)", @@ -1047,6 +1064,13 @@ "generating": "生成中", "generatingWithElapsed": "生成中 · {{seconds}}s", "scrollToBottom": "回到底部", + "turnTimeline": { + "label": "对话回合", + "jumpToQuery": "跳转到第 {{index}} 条消息", + "userFallback": "(空消息)", + "emptyAssistant": "(暂无回复)", + "runningAssistant": "(生成中…)" + }, "loadingEarlierMessages": "正在加载更早的消息…", "scrollForEarlierMessages": "向上滚动加载更早的消息", "refreshingMessages": "正在刷新消息…", @@ -1158,12 +1182,96 @@ "emptyResult": "模型未返回润色结果" }, "hitl": { - "title": "需要审批工具调用", + "title": "需要确认这次操作", "approve": "批准", "reject": "拒绝", "approved": "已批准", "rejectedLabel": "已拒绝", - "rejected": "用户已拒绝" + "rejected": "用户已拒绝", + "args": { + "action": "操作", + "level": "范围", + "url": "网址", + "command": "命令", + "cmd": "命令", + "path": "路径", + "file": "文件", + "file_path": "文件路径", + "query": "查询", + "pattern": "匹配", + "content": "内容", + "text": "文字", + "selector": "选择器", + "ref": "元素", + "key": "按键", + "value": "值", + "direction": "方向", + "amount": "幅度", + "prompt": "提示词", + "old_string": "原文", + "new_string": "替换为", + "timeout": "超时", + "method": "方法", + "body": "内容", + "code": "脚本", + "wait": "等待", + "element_ref": "目标元素", + "full_page": "整页", + "tab_id": "标签页" + }, + "bool": { + "true": "是", + "false": "否" + }, + "browser": { + "actions": { + "navigate": "打开网页", + "open": "打开网页", + "snapshot": "获取页面快照", + "dom_tree": "获取网页结构", + "screenshot": "截取网页截图", + "click": "点击页面元素", + "type": "在页面中输入文字", + "fill": "填写表单", + "press": "按下按键", + "wait": "等待页面就绪", + "select": "选择下拉选项", + "scroll": "滚动页面", + "hover": "悬停在元素上", + "eval_js": "在页面中执行脚本", + "go_back": "返回上一页", + "go_forward": "前进到下一页", + "reload": "刷新页面", + "new_tab": "打开新标签页", + "switch_tab": "切换标签页", + "close_tab": "关闭标签页", + "list_tabs": "列出标签页", + "close_session": "关闭浏览器会话" + }, + "levels": { + "minimal": "精简结构", + "interactive": "仅交互元素", + "full": "完整结构", + "structured": "结构化内容" + }, + "directions": { + "up": "向上", + "down": "向下", + "left": "向左", + "right": "向右" + } + }, + "summaries": { + "domTreeInteractive": "获取当前网页的可交互元素结构", + "domTree": "获取当前网页结构", + "screenshot": "截取当前网页截图", + "openUrl": "打开网页 {{url}}", + "runCommand": "执行命令:{{command}}", + "writeFile": "写入文件 {{path}}", + "readFile": "读取文件 {{path}}", + "editFile": "编辑文件 {{path}}", + "fetchUrl": "抓取网页 {{url}}" + } }, "ask": { "title": "先问几个问题", @@ -1176,6 +1284,8 @@ "submit": "提交回答", "skip": "你决定", "skipMessage": "你决定吧——按最优默认方案继续,并告诉我你依据的假设。", + "dismiss": "关闭", + "dismissMessage": "用户关闭了这些问题,没有作答。不要再追问,请就此结束本轮。", "answered": "已回答", "answeredSummary": "已回答 {{count}} 个问题", "dismissedSummary": "已关闭 {{count}} 个问题", @@ -1185,6 +1295,7 @@ "openBrowser": "查看浏览器", "openBrowserHint": "Agent 正在网页中操作", "openTerminal": "打开终端", + "openTrajectory": "运行轨迹", "openPhone": "打开远程手机", "openWorkspace": "工作区", "editFileCard": "编辑了{{count}}个文件", @@ -1200,6 +1311,57 @@ "dockFileMaybeDeleted": "该文件可能为处理过程中的临时文件,当前已经被删除。", "remoteBrowserTitle": "远程浏览器", "dockTerminalTitle": "终端", + "trajectoryTitle": "运行轨迹", + "trajectoryEmpty": "暂无运行轨迹", + "trajectorySelectSession": "请选择一个会话以查看运行轨迹", + "trajectoryLoadError": "加载运行轨迹失败", + "trajectoryLoadEarlier": "加载更早历史", + "trajectoryLaneInput": "输入", + "trajectoryLaneModel": "模型", + "trajectoryLaneTools": "工具", + "trajectoryTimeline": "运行轨迹时间线", + "trajectoryMetrics": "会话指标", + "trajectoryExport": "导出", + "trajectoryExportFailed": "导出运行轨迹失败", + "trajectoryMetricTurns": "回合", + "trajectoryMetricSteps": "步骤", + "trajectoryMetricLlmMs": "模型", + "trajectoryMetricToolMs": "工具", + "trajectoryMetricTtft": "首字时延", + "trajectoryMetricTokPerS": "tok/s", + "trajectoryMetricCacheHit": "缓存命中", + "trajectoryMetricInputTokens": "输入", + "trajectoryMetricOutputTokens": "输出", + "trajectoryMetricCacheRead": "缓存读取", + "trajectoryDetailLoading": "正在加载详情…", + "trajectoryDetailError": "加载事件详情失败", + "trajectoryToolbarDuration": "耗时", + "trajectoryToolbarTurns": "回合", + "trajectoryToolbarCalls": "调用", + "trajectoryToolbarSearch": "搜索轨迹", + "trajectoryToolCallOnly": "(仅工具调用)", + "trajectoryCollapsedToolCallOne": "{{count}} 次工具调用 · {{names}}", + "trajectoryCollapsedToolCallMany": "{{count}} 次工具调用 · {{names}}", + "trajectoryCollapsedTurn": "{{steps}} 步 · {{toolCalls}} 次工具调用", + "trajectoryInspectorKind": "类型", + "trajectoryInspectorSource": "来源", + "trajectoryInspectorStatus": "状态", + "trajectoryInspectorError": "错误", + "trajectoryInspectorCompleted": "已完成", + "trajectoryInspectorTtft": "首字时延", + "trajectoryInspectorTokens": "Token", + "trajectoryInspectorTiming": "请求耗时", + "trajectoryInspectorStarted": "开始时间", + "trajectoryInspectorTotalDuration": "总耗时", + "trajectoryInspectorGeneration": "生成耗时", + "trajectoryInspectorThroughput": "吞吐", + "trajectoryInspectorPlaceholder": "选择一条记录", + "trajectoryInspectorSummary": "摘要", + "trajectoryInspectorPreview": "预览", + "trajectoryInspectorResult": "结果", + "trajectoryInspectorPayload": "参数", + "trajectoryInspectorEmptyPayload": "无参数", + "trajectoryInspectorRaw": "原始", "dockPhoneTitle": "远程手机", "dockToolUiTitle": "插件工具", "dockToolUiMissing": "该工具结果已不在当前对话中。", @@ -1259,7 +1421,7 @@ }, "toolSettings": { "title": "工具设置", - "hint": "关闭后模型将无法调用该工具。系统必需工具不可关闭。插件工具需插件已全局启用;也可在「插件管理」里按专家开关。", + "hint": "关闭后模型将无法调用该内置工具。系统必需工具不可关闭。", "empty": "暂无工具", "loadFailed": "加载工具设置失败", "saveSuccess": "工具设置已保存", @@ -1340,7 +1502,7 @@ "archiveImportSuccess": "已导入 {{count}} 个文件", "archiveImportFailed": "导入压缩包失败", "archiveImportTitle": "导入工作区压缩包", - "archiveImportBody": "将导入「{{name}}」到当前 Agent 工作区。", + "archiveImportBody": "将导入「{{name}}」到当前专家工作区。", "archiveImportConfirm": "开始导入", "archiveModeMerge": "合并(覆盖同名文件,保留其他文件)", "archiveModeReplace": "替换(清空本地工作区后导入)", @@ -1377,6 +1539,16 @@ "storedTitle": "备份文件", "storedDesc": "系统备份保存在以下目录,可下载或一键恢复。", "createButton": "新建备份", + "createModalTitle": "选择备份内容", + "createModalDesc": "数据库基础数据始终备份,其他内容可按需选择。", + "includeDatabase": "数据库基础数据(必选)", + "includeConfig": "配置文件(config.json 与 env)", + "includeWorkspaces": "专家工作区", + "includeSkillPackages": "全局技能包(skill-packages)", + "includePlugins": "已安装插件(plugins)", + "includeKnowledge": "知识库文件(knowledge)", + "includeChats": "聊天记录(含运行轨迹)", + "includeChatsHint": "聊天记录和运行轨迹可能占用大量空间,默认不备份。", "creating": "备份中…", "uploadButton": "上传备份", "uploadSuccess": "已保存 {{name}}", @@ -1389,6 +1561,7 @@ "colSize": "大小", "colCreated": "创建时间", "colModified": "修改时间", + "colContents": "内容", "colActions": "操作", "restoreAction": "恢复", "deleteConfirmTitle": "删除备份", @@ -1404,15 +1577,27 @@ "importButton": "选择备份文件", "uploading": "正在上传… {{percent}}%", "restoring": "恢复中…", - "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。恢复期间依赖数据库的接口可能短暂不可用,建议在维护窗口操作。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", + "importWarning": "恢复只覆盖备份中包含的内容;未包含聊天记录时会保留当前聊天记录。恢复期间依赖数据库的接口可能短暂不可用,建议在维护窗口操作。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", "importConfirmTitle": "确认恢复备份", "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据,替换数据库期间服务可能短暂不可用。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", "importConfirmOk": "恢复", "importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)", "importFailed": "恢复备份失败", "restoreConfig": "同时恢复 config.json 与 env(需手动重启服务后生效)", + "tagConfig": "配置", + "tagWorkspaces": "工作区", + "tagSkillPackages": "技能包", + "tagPlugins": "插件", + "tagKnowledge": "知识库", + "tagChatsYes": "对话及运行轨迹", + "restoreKeepChats": "此备份不含聊天记录和运行轨迹,恢复后会保留当前数据。", + "restoreNoConfig": "此备份不含配置文件,无法恢复 config/env。", + "restoreNoWorkspaces": "此备份不含专家工作区,磁盘上现有工作区文件不会被整包替换。", + "restoreNoSkillPackages": "此备份不含全局技能包,现有 skill-packages 目录会保留。", + "restoreNoPlugins": "此备份不含插件目录,现有 plugins 会保留。", + "restoreNoKnowledge": "此备份不含知识库文件,现有 knowledge 目录会保留。", "autoTitle": "自动备份", - "autoDesc": "按计划将全量系统备份写入备份目录;保留策略只清理自动备份,不影响手动备份。", + "autoDesc": "按计划将系统备份写入备份目录;默认不包含聊天记录。保留策略只清理自动备份,不影响手动备份。", "autoEnabled": "启用自动备份", "autoSchedule": "备份周期", "autoScheduleDaily": "每天 04:00", @@ -1422,6 +1607,7 @@ "autoScheduleHint": "cron:分 时 日 月 周(服务器时区);interval:秒数,例如 interval:43200 表示每 12 小时。", "autoIntervalPreview": "当前:每 {{seconds}} 秒(约 {{hours}} 小时)", "autoRetention": "保留最近 N 份自动备份", + "autoContent": "备份内容", "autoSave": "保存设置", "autoSaveSuccess": "自动备份设置已保存", "autoSaveFailed": "保存自动备份设置失败", @@ -1603,7 +1789,7 @@ "cronJobs": { "title": "定时任务", "description": "配置和监控定时任务", - "createJob": "创建任务", + "createJob": "创建定时任务", "editJob": "编辑任务", "confirmDelete": "确认删除", "deleteConfirm": "确定要删除此定时任务吗?", @@ -1629,7 +1815,7 @@ "loadingJobs": "正在加载当前专家的任务…", "syncing": "正在同步…", "noJobs": "开启你的第一个定时任务吧", - "noJobsDesc": "选择下方示例,或点击「创建任务」从头开始", + "noJobsDesc": "选择下方示例,或点击「创建定时任务」从头开始", "viewTable": "表格", "viewCard": "卡片", "noJobsSuggestion1": "设置每日「14:00」的自动定时任务,根据我的星座「双子座」提供今日的行动注意事项发送给我", @@ -1662,6 +1848,7 @@ "idTooltip": "任务的唯一标识符,由系统自动生成。", "name": "任务名称", "nameTooltip": "给任务起一个易于辨识的名称,方便在列表中快速找到。", + "nameTooLong": "任务名称不能超过 {{max}} 个字符", "enabled": "启用任务", "enabledTooltip": "开启后任务将按照设定的时间自动执行,关闭则暂停执行。", "sectionSchedule": "执行频率", @@ -1784,7 +1971,7 @@ "runtimeTimeoutSeconds": "超时时间 (秒)", "runtimeMisfireGrace": "错过容忍 (秒)" }, - "noAgentSelected": "请先在右上角选择一个 Agent 来管理它的定时任务" + "noAgentSelected": "请先在右上角选择一个专家来管理它的定时任务" }, "channels": { "title": "通道", @@ -1863,7 +2050,7 @@ "displaySettings": "消息展示", "responseMode": "回复方式", "responseModeDesc": "仅发送最终回复会隐藏工具调用前的过程说明;实时过程保留当前逐阶段消息", - "responseModeInvoke": "仅最终回复(推荐)", + "responseModeInvoke": "仅最终回复", "responseModeStream": "实时过程", "showToolHints": "显示工具调用提示", "showToolHintsDesc": "开启后,频道消息中将展示工具调用过程与状态提示", @@ -1885,27 +2072,27 @@ "checkRequestFailed": "连接检测请求失败,请稍后重试", "checkFailedAutoDisabled": "凭据验证失败,频道已自动停用", "enableFailedCheckFailed": "启用失败:连接检测未通过", - "quickConfig": "扫码接入", - "manualConfig": "手动配置", + "quickConfig": "快捷配置(扫码)", + "manualConfig": "手动填写", "quickConfigStep1": "点击下方按钮前往企业微信开放平台", "quickConfigStep2": "按照指引完成机器人授权", "quickConfigStep3": "授权完成后回到此页面,频道将自动连接", "goToAuthorize": "前往授权", - "qrLoading": "正在获取二维码...", - "qrStep1": "打开企业微信 App", - "qrStep2": "扫描下方二维码完成授权", - "qrStep3": "扫码成功后自动获取凭据并接入", - "qrScanHint": "请使用企业微信扫描二维码", + "qrLoading": "生成二维码中...", + "qrStep1": "打开企业微信", + "qrStep2": "扫码注册 AI 机器人", + "qrStep3": "确认绑定", + "qrScanHint": "扫码后自动跳转下一步", "weixinQrStep1": "打开微信 App", - "weixinQrStep2": "扫描下方二维码完成授权", - "weixinQrStep3": "扫码成功后自动获取凭据并接入", - "weixinQrScanHint": "请使用微信扫描二维码", - "dingtalkQrLoading": "正在初始化钉钉应用创建流程...", + "weixinQrStep2": "扫码登录", + "weixinQrStep3": "手机确认", + "weixinQrScanHint": "使用微信扫码登录个人账号", + "dingtalkQrLoading": "生成二维码中...", "dingtalkQrIntro1": "点击按钮生成钉钉应用授权二维码", "dingtalkQrIntro2": "使用同一组织账号登录的钉钉 App 扫码并确认", "dingtalkQrIntro3": "Octop 将自动创建并启用钉钉频道", - "dingtalkQrStep1": "打开钉钉 App", - "dingtalkQrStep2": "扫描应用授权二维码", + "dingtalkQrStep1": "打开钉钉", + "dingtalkQrStep2": "扫描二维码", "dingtalkQrStep3": "确认创建应用", "dingtalkQrScanHint": "请使用钉钉 App 扫码完成授权", "dingtalkUserCode": "用户码", @@ -1946,21 +2133,31 @@ "qrTimeout": "扫码超时(3 分钟),请重试", "qrFailed": "扫码失败,请重试", "qrGenerateFailed": "获取二维码失败,请检查网络后重试", - "qrRetry": "重新获取二维码", - "feishuCreating": "正在初始化飞书机器人创建流程...", + "qrRetry": "重新生成", + "feishuCreating": "生成二维码中...", "feishuApiTimeout": "调用飞书接口超时,请使用手动配置", "feishuSwitchManual": "使用手动配置", - "feishuQrStep1": "打开飞书 App,点击右上角「+」→「扫一扫」", - "feishuQrStep2": "扫描下方二维码登录飞书开放平台", - "feishuQrScanHint": "请使用飞书 App 内的「扫一扫」扫描二维码", + "feishuQrStep1": "打开飞书扫一扫", + "feishuQrStep2": "扫描二维码", + "feishuQrStep3": "确认创建应用", + "feishuQrScanHint": "请使用飞书 App 内的「扫一扫」扫描,确认后即可拿到凭证", + "feishuQrIntro1": "点击下方按钮,生成飞书 AI 机器人注册二维码", + "feishuQrIntro2": "用飞书 App 扫码,完成机器人注册", + "feishuQrIntro3": "凭据自动填入并保存", + "feishuCreateButton": "一键创建飞书机器人", + "feishuCreateFailed": "飞书机器人创建失败", "feishuBindSuccess": "飞书机器人已创建并启用", "feishuCreateSuccess": "飞书机器人创建成功!", + "feishuCreateSuccessNamed": "飞书机器人「{{name}}」创建成功", "feishuBotName": "机器人名称", "feishuManageBot": "前往飞书开放平台管理", - "yuanbaoCreating": "正在初始化元宝机器人绑定流程...", - "yuanbaoQrStep1": "打开腾讯元宝 App", - "yuanbaoQrStep2": "扫描下方二维码完成机器人绑定", - "yuanbaoQrScanHint": "请使用腾讯元宝 App 扫描二维码", + "yuanbaoCreating": "生成二维码中...", + "yuanbaoQrStep1": "打开元宝 App", + "yuanbaoQrStep2": "扫码绑定", + "yuanbaoQrStep3": "确认授权", + "yuanbaoQrScanHint": "扫码后在元宝 App 确认绑定", + "yuanbaoStarting": "启动元宝扫码绑定流程...", + "yuanbaoBindFailed": "元宝绑定失败", "yuanbaoBindSuccess": "元宝机器人已绑定并启用", "yuanbaoCreateSuccess": "元宝机器人绑定成功!", "dmPolicy": "私聊策略", @@ -1986,6 +2183,16 @@ "qrRetryBtn": "重试", "wecomGenerateQr": "生成扫码二维码", "weixinGenerateQr": "生成微信扫码二维码", + "wecomQrSuccess": "企业微信绑定成功", + "weixinQrSuccess": "微信绑定成功", + "weixinAccountId": "账号 ID: {{id}}", + "fieldRequired": "{{label}} 必填", + "fieldMustBeJsonObject": "{{label}} 必须是 JSON 对象", + "jsonMustBeObject": "必须是 JSON 对象", + "invalidJson": "非法 JSON", + "rawConfigTooltip": "渠道特定配置 — 详见 harness-gateway 文档", + "getCredentials": "前往获取凭据", + "channelSettingsNamed": "{{kind}} 频道设置", "deleteConfirmTitle": "删除频道「{{name}}」?" }, "tokenUsage": { @@ -2146,6 +2353,7 @@ "voice": { "loading": "加载语音配置…", "loadError": "加载语音配置失败", + "description": "管理语音识别与语音合成模型,并选择当前使用的服务。", "sttSection": "语音输入 (STT)", "ttsSection": "语音朗读 (TTS)", "free": "免费", @@ -2156,7 +2364,12 @@ "activeUpdated": "已更新当前语音服务", "activeUpdateFailed": "更新失败", "configure": "配置", + "configureTitle": "配置 {{name}}", "saved": "配置已保存", + "credentialsRequired": "请先填写完整的连接凭证", + "probe": "探测", + "probeSuccess": "语音模型探测成功", + "probeFailed": "语音模型探测失败", "tencentHint": "在腾讯云控制台创建 SecretId / SecretKey,语音识别与合成均有免费试用额度。", "openaiHint": "使用 OpenAI Whisper 识别、OpenAI TTS 朗读。", "mimoHint": "小米 Mimo 语音识别与合成。选择接入点并填写对应的 API Key。", @@ -2594,9 +2807,11 @@ "ollamaNotRunning": "Ollama 服务未启动", "testUseSavedConfig": "使用已保存的配置", "pageTitle": "模型设置", - "pageSubtitle": "统一管理对话模型与图片、视频生成模型服务。", + "pageSubtitle": "统一管理对话模型、图片视频生成模型、语音模型与搜索引擎。", "chatModelsTab": "对话模型", "generationModelsTab": "生成模型", + "voiceModelsTab": "语音模型", + "searchModelsTab": "搜索引擎", "addCustomProvider": "自定义供应商", "loadingProviders": "加载中...", "noProvidersHint": "还没有 provider,点击右上角新建", @@ -2708,9 +2923,11 @@ "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", "search": { - "desc": "配置 AI 驱动的网络搜索提供商,使您的助手能够搜索互联网。每个提供商需要来自相应服务的 API 密钥。", + "desc": "管理网络搜索提供商,供智能体检索互联网。", "tip": "您可以在此处配置或修改搜索提供商。更改将在下一次聊天会话时生效。", "configure": "配置", + "configureTitle": "配置 {{name}}", + "probe": "探测", "sourceBuiltinTitle": "当前搜索源:内置搜索", "sourceBuiltinDesc": "未配置第三方搜索服务时,仍可使用产品内置搜索服务;该服务无需 API Key,但不保证稳定性和可用性。配置第三方服务后会自动切换。", "sourceConfiguredTitle": "当前搜索源:{{name}}", @@ -2800,6 +3017,8 @@ "maxInputLengthPlaceholder": "请输入最大输入长度", "maxInputLengthRequired": "最大输入长度为必填项", "maxInputLengthMin": "最大输入长度必须大于等于 1000", + "enableTrajectory": "记录运行轨迹", + "enableTrajectoryTooltip": "开启后,该专家的对话会写入运行轨迹,可在聊天页查看。默认开启。", "saveSuccess": "配置保存成功", "saveFailed": "配置保存失败", "loadFailed": "配置加载失败", @@ -3415,10 +3634,11 @@ }, "personalization": { "title": "个性化", - "description": "配置当前智能体的技能、工具、子智能体、通道、人格与记忆。", + "description": "配置当前智能体的技能、工具、插件、子智能体、通道、人格与记忆。", "tabs": { "skills": "技能", "tools": "工具", + "plugins": "插件", "subagents": "子智能体", "channels": "通道", "mbti": "MBTI", @@ -3608,9 +3828,9 @@ "launch": "启动浏览器", "connect": "连接", "stop": "关闭浏览器", - "shutdownTitle": "关闭浏览器进程", - "shutdownConfirm": "将结束本机 Chrome 进程并释放内存。登录状态会保留在磁盘,下次启动仍可复用。是否继续?", - "shutdownFailed": "关闭浏览器进程失败", + "shutdownTitle": "关闭浏览器", + "shutdownConfirm": "将关闭当前浏览器窗口。已登录的网站下次打开时仍然有效。", + "shutdownFailed": "关闭浏览器失败", "streaming": "推流中", "connecting": "连接中", "browserStarted": "启动中", @@ -3630,8 +3850,8 @@ "viewportMode": "视口模式", "checkInstall": "检查浏览器", "checkInstallShort": "检查", - "checkInstallTip": "检查 Playwright 浏览器是否已安装,未安装则自动安装", - "browserAlreadyInstalled": "浏览器已安装", + "checkInstallTip": "检查本机是否已准备好浏览器,未安装时可一键安装", + "browserAlreadyInstalled": "浏览器已就绪", "checkFailed": "检查浏览器状态失败", "installingBrowser": "安装浏览器", "installing": "正在启动安装...", @@ -3639,21 +3859,21 @@ "installSuccessHint": "浏览器已就绪,可启动会话", "installFailed": "安装失败", "installFailedHint": "自动安装失败,请重试。若网络不稳定,可配置 PLAYWRIGHT_DOWNLOAD_HOST 镜像源后再次安装。", - "notInstalled": "Chromium 未安装", - "notInstalledHint": "尚未检测到可用浏览器,请点击下方按钮自动安装内置 Chromium。", + "notInstalled": "未检测到可用浏览器", + "notInstalledHint": "Octop 需要浏览器才能帮你自动打开网页、填写表单和截图。点击下方按钮即可自动安装,无需手动配置。", "install": "安装浏览器", "installProgress": "正在安装中…", "installCancelHint": "已取消安装请求,服务端可能仍在继续安装,请稍后刷新状态。", "uninstall": "卸载", - "uninstallTitle": "删除 Playwright Chromium", - "uninstallConfirm": "将关闭 Octop 浏览器会话,并删除通过 Playwright 安装的 Chromium。不会影响本机已有的 Chrome/Chromium。是否继续?", + "uninstallTitle": "卸载内置浏览器", + "uninstallConfirm": "将关闭当前浏览器窗口,并卸载 Octop 自动安装的浏览器。你电脑上已有的 Chrome 等浏览器不受影响。", "uninstalling": "正在卸载…", - "uninstallSuccess": "已删除 Playwright Chromium", + "uninstallSuccess": "内置浏览器已卸载", "uninstallFailed": "卸载失败", "playwrightMissing": "playwright 包未安装,请先安装 octop[browser] extras。", "installLog": "安装日志", "installRetry": "重新安装", - "envReady": "Playwright 与 Chromium 均可用", + "envReady": "可以帮你打开网页、填写表单和截图了", "envProbeFailed": "检测浏览器环境失败", "sharedSessionHint": "与聊天中 Agent 共用同一浏览器会话", "selectSession": "选择浏览器会话", @@ -3672,10 +3892,10 @@ "startBrowserDesc": "环境已就绪,按以下步骤开始远程浏览与操控", "startBrowserIdleStep1": "点击下方「启动浏览器」建立会话", "startBrowserIdleStep2": "在地址栏输入网址并访问,也可使用收藏夹与 AI 助手", - "setupTitle": "需要配置浏览器环境", - "setupDesc": "按以下步骤完成 Playwright / Chromium 环境配置", - "setupStep1": "点击「检查」,检测 Playwright 与 Chromium 是否可用", - "setupStep2": "若组件缺失,在弹窗中一键安装浏览器环境", + "setupTitle": "需要安装浏览器", + "setupDesc": "先安装浏览器,即可开始远程浏览和自动操作网页", + "setupStep1": "点击「检查」,确认本机是否已有可用浏览器", + "setupStep2": "如果还没有,在弹窗中一键安装即可", "setupStep3": "安装完成后,点击「启动浏览器」开始会话", "startBrowserDisabled": "请先完成浏览器环境检查与安装", "startBrowserHeaderHint": "点击下方「启动浏览器」开始会话", @@ -3843,7 +4063,9 @@ "active": "运行中" }, "browserStatusActive": "浏览器:{{owner}}", - "browserStatusIdle": "浏览器(点击查看/接管)" + "browserStatusIdle": "浏览器(点击查看/接管)", + "chromeMissingTitle": "检测到未安装浏览器", + "chromeMissingJumpToInstall": "即将跳转「工作区-浏览器」进行安装" }, "browserViewer": { "goBack": "后退", @@ -4041,7 +4263,7 @@ }, "storage": { "pageTitle": "存储管理", - "pageSubtitle": "管理存储后端(COS、S3、OSS、Docker 沙箱等),供 Agent 使用", + "pageSubtitle": "管理存储后端(本地、对象存储、沙箱、数据库),供专家使用", "myStorage": "我的存储", "supportedTypes": "支持类型", "addBackend": "添加存储后端", @@ -4051,7 +4273,15 @@ "clickToConfigure": "点击配置此后端", "configuredBadge": "已配置", "emptyMyStorage": "暂无存储后端", - "emptyMyStorageHint": "前往「支持类型」选择并配置一个存储后端,供 Agent 使用", + "emptyMyStorageHint": "前往「支持类型」选择并配置一个存储后端,供专家使用", + "emptyGuideTitle": "还没有存储后端", + "emptyGuideDesc": "存储后端是给专家用的工作区:本地目录、对象存储、沙箱或数据库,配好后按名称挂到专家上即可。", + "emptyGuideStepWhat": "存储后端是什么", + "emptyGuideStepWhatDetail": "一份可复用的存储配置。专家读写文件、执行命令时走这份后端,而不是默认的本机目录。", + "emptyGuideStepHow": "怎么添加", + "emptyGuideStepHowDetail": "打开「支持类型」,选本地、对象存储、沙箱或数据库,填好凭证或路径后保存。可随时探测连通性。", + "emptyGuideStepUse": "专家怎么用", + "emptyGuideStepUseDetail": "在专家工作区里选择已配置的后端名称。沙箱类后端随专家启动创建、停止销毁。", "totalBackends": "共 {{count}} 个后端", "noBackends": "暂无存储后端,点击右上角添加", "nameLabel": "名称", @@ -4075,6 +4305,15 @@ "dockerImageLabel": "镜像(image)", "dockerRegistryLabel": "Registry", "dockerPulling": "正在拉取 Docker 镜像…", + "opensandboxInstalling": "正在安装 OpenSandbox SDK 并探测…", + "opensandbox_probe_ok": "OpenSandbox 读写探测成功", + "opensandboxDomainLabel": "域名", + "opensandboxApiKeyLabel": "API Key", + "opensandboxProtocolLabel": "协议(http / https)", + "groupLocal": "本地", + "groupObject": "对象存储", + "groupSandbox": "沙箱", + "groupDatabase": "数据库", "docker_image_pulled": "镜像已拉取并就绪", "docker_image_ready": "镜像已在本地,可用", "docker_probe_roundtrip_ok": "容器内写入与读取探测成功", @@ -4100,16 +4339,18 @@ "kindShell": "本地 Shell", "kindPostgres": "PostgreSQL", "kindDocker": "Docker 沙箱", + "kindOpensandbox": "OpenSandbox", "kindCustom": "自定义(S3 兼容)", - "descCos": "腾讯云对象存储,适合国内业务;支持大文件上传、CDN 加速", - "descS3": "Amazon S3,业界标准对象存储;兼容绝大多数云厂商", - "descOss": "阿里云对象存储 OSS,适合阿里云生态的应用场景", - "descObs": "华为云对象存储 OBS,适合华为云生态", - "descFilesystem": "挂载宿主机本地目录,适合开发/单机部署场景", - "descShell": "在宿主机本地 Shell 中执行命令,支持超时、环境变量隔离", - "descPostgres": "将 PostgreSQL 表用作结构化存储后端,支持 Schema 隔离", - "descDocker": "在 Docker 容器内执行代码(经 Docker API,不挂载宿主工作区);按 sandbox_scope 命名容器;删专家不删沙箱", - "descCustom": "任意 S3 兼容服务(MinIO、Ceph、R2 等)", + "descCos": "腾讯云对象存储,国内访问延迟低;支持 CDN 加速与大文件分片上传,适合国内部署。", + "descS3": "Amazon S3,业界标准对象存储;协议兼容绝大多数云厂商与自建服务,适合跨区域部署。", + "descOss": "阿里云对象存储 OSS,适合阿里云生态;内网访问免流量费,支持生命周期与冷热分层。", + "descObs": "华为云对象存储 OBS,适合华为云生态;兼容 S3 接口,支持多级存储与并行文件语义。", + "descFilesystem": "挂载宿主机本地目录作为工作区,读写最快、无网络依赖;适合开发调试与单机部署。", + "descShell": "在宿主机本地 Shell 中执行命令,支持超时与环境变量隔离;专家可直接使用宿主工具链。", + "descPostgres": "将 PostgreSQL 表用作结构化存储后端,支持 Schema 隔离;多个专家可共享同一实例。", + "descDocker": "在本机 Docker 容器内执行代码,不挂载宿主工作区;容器按 sandbox_scope 复用与命名。", + "descOpensandbox": "远程 OpenSandbox 沙箱,专家启动时创建、停止时销毁;启用该后端会自动安装官方 SDK。", + "descCustom": "任意 S3 兼容服务,如 MinIO、Ceph、Cloudflare R2;需自行填写 Endpoint 与访问密钥。", "dockerEnv": { "drawerTitle": "本机 Docker 环境", "checking": "正在检测 Docker…", @@ -4495,8 +4736,23 @@ "loadFailed": "加载连接器失败", "tabCatalog": "服务目录", "tabBuiltin": "内置连接器", + "tabEnabled": "已启用连接器", "tabCustom": "自定义连接器", "listSummary": "当前支持 {{total}} 个连接器,已配置 {{configured}} 个", + "enabledSummary": "已启用 {{count}} 个连接器实例", + "emptyGuideTitle": "还没有已启用的连接器", + "emptyGuideDesc": "连接器实例保存一份外部服务的凭证,Agent 通过它调用对方的工具。请基于内置连接器创建实例。", + "emptyGuideStepWhat": "连接器实例是什么", + "emptyGuideStepWhatDetail": "一个实例 = 一份凭证 + 一个显示名称。同一个内置连接器可以创建多个实例,例如两个不同账号的邮箱。", + "emptyGuideStepHow": "怎么创建", + "emptyGuideStepHowDetail": "打开「内置连接器」,选择要接入的服务,在弹出的「创建连接器」中填写凭证并探测,保存后即出现在本页。", + "emptyGuideStepShare": "共享与默认开启", + "emptyGuideStepShareDetail": "开启共享后本实例内所有登录用户可选用,但仅所有者可编辑;默认开启只对所有者生效。", + "emptyGuideBrowseBuiltin": "浏览内置连接器", + "emptyGuideAddCustom": "添加自定义 MCP", + "noCredentials": "缺少凭证", + "sharedFrom": "来自 {{name}}", + "sharedReadonly": "共享连接器,仅所有者可管理", "customMcp": { "introTitle": "MCP 服务器配置(JSON 格式)。参考以下格式:", "modeVisual": "可视化", @@ -4622,7 +4878,12 @@ "comingSoon": "即将推出", "addConnection": "添加 {{name}}", "displayName": "显示名称", - "defaultOpen": "是否默认打开", + "description": "描述", + "shared": "是否共享", + "sharedHint": "共享后其他用户可以选择使用,但不能查看或修改配置。", + "sharedBadge": "共享", + "defaultEnabled": "是否默认开启", + "defaultOpen": "是否默认开启", "defaultOpenHint": "仅对你自己的账号生效:关闭时需在对话中手动勾选才会注入工具。", "defaultOpenWarning": "开启后默认会在你的 Dashboard、IM 与 Cron(未特殊选连接器时)携带该工具(额外消耗 token)。Dashboard 可关本轮;Cron 若显式选择连接器则以选择为准。", "defaultOpenLockedBadge": "默认打开", @@ -4637,8 +4898,9 @@ "createSuccess": "连接器已创建", "createFailed": "创建失败", "saveSuccess": "连接器已保存", - "configureConnection": "配置 {{name}}", - "editConnection": "配置 {{name}}", + "configureConnection": "创建 {{name}} 连接器", + "createConnection": "创建 {{name}} 连接器", + "editConnection": "编辑 {{name}} 连接器", "configSection": "连接配置", "secretConfigured": "已配置,留空表示不修改", "secretPlaceholder": "留空表示不修改", @@ -4744,18 +5006,23 @@ "uninstallFailed": "卸载失败", "uninstallConfirm": "确定卸载插件 {{id}}?", "empty": "尚未安装插件", - "adminHint": "插件安装在服务器 ~/.octop/plugins/。可用启用开关控制插件是否加载。打开「查看详情」可为侧栏当前 Agent 开关工具。", + "adminHint": "插件安装在服务器 ~/.octop/plugins/。此页面仅用于安装、重载、全局启停、查看详情和卸载。", "guideTitle": "如何开发并导入插件", "guideDevelopTitle": "1. 开发", "guideDevelopBody": "创建一个包含 plugin.yaml 与入口 Python 文件(如 main.py)的目录。plugin.yaml 需包含 id、version、name、kind(tool / skill / hook)和 entry。setup(ctx) 中:tool → ctx.tool(...);skill → ctx.skills(\"skills\");hook → ctx.middleware(...)。仓库 plugins/ 下有三类 demo 可参考。", "guidePackageTitle": "2. 打包", "guidePackageBody": "将插件打成 ZIP,且压缩包内只能有一个带 plugin.yaml 的插件根目录(可在 ZIP 根目录,或唯一的一层子目录中)。示例:zip -r my-plugin.zip my-plugin/", "guideImportTitle": "3. 导入", - "guideImportBody": "把 .zip 放到 Octop 可通过 HTTP(S) 下载的位置,优先使用 raw 直链。安装后打开插件「查看详情」,在侧栏选好 Agent,再启用工具。本地 demo:octop plugin install ./plugins/demo-toolkit --force", + "guideImportBody": "把 .zip 放到 Octop 可通过 HTTP(S) 下载的位置,优先使用 raw 直链。安装后前往「个性化 → 插件」为 Agent 配置。本地 demo:octop plugin install ./plugins/demo-toolkit --force", "guideExampleTitle": "最小 plugin.yaml 示例", "guideExampleYaml": "id: my-plugin\nversion: 1.0.0\nname: My Plugin\ndescription: Example tool plugin\nicon: \"🧩\"\nkind: tool\nentry: main.py", - "agentHint": "插件启用后,其工具默认对所有 Agent 可用;可在详情里按 Agent 单独关闭。需要 API Key 的插件请先点「配置」。新会话会使用最新工具列表。", + "agentHint": "插件启用后,其工具默认对所有 Agent 可用;可按 Agent 单独关闭。需要 API Key 的插件请先点「配置」。新会话会使用最新工具列表。", + "agentPluginHint": "已安装插件默认对当前专家开启。关闭后,该插件的工具、技能和 Hook 均不再用于此专家,但已有工具配置会保留。", + "agentEnabled": "为当前 Agent 启用", + "globallyDisabled": "已全局关闭", + "globalDisabledHint": "管理员已全局关闭此插件,因此即使当前 Agent 开启也不可用。", "noAgent": "请先在侧栏选择一个 Agent", + "noPlugins": "暂无已安装插件,请联系管理员先安装插件。", "noTools": "没有可用的插件工具。请先安装 tool 类插件。", "enablePluginFirst": "请先启用该插件。启用后工具默认可用,也可按 Agent 单独关闭。", "detailToolsHint": "默认开启。关闭后仅对侧栏当前选中的 Agent 生效(与专家「工具设置」共用配置);下一轮对话即可生效,无需重启。", @@ -4838,8 +5105,8 @@ "subtitle": "创建与管理你的 AI 专家,也可从模板库挑选场景一键新建。" }, "tasks": { - "title": "定时任务", - "subtitle": "配置 Agent 定期执行的计划任务" + "title": "自动化", + "subtitle": "配置专家定期执行的计划任务" }, "connectors": { "title": "连接器", @@ -4883,7 +5150,7 @@ }, "models": { "title": "模型配置", - "subtitle": "配置 LLM 提供商和 API 密钥" + "subtitle": "配置对话模型、生成模型、语音模型与搜索引擎" }, "voice": { "title": "语音服务", @@ -4911,7 +5178,7 @@ }, "adminAdvanced": { "title": "应用设置", - "subtitle": "环境变量、搜索、语音、备份、HTTPS 与更新" + "subtitle": "环境变量、备份、HTTPS 与更新" }, "security": { "title": "安全防护", @@ -4919,7 +5186,7 @@ }, "adminPlugins": { "title": "插件管理", - "subtitle": "安装与管理服务器插件,在详情中为 Agent 启用工具" + "subtitle": "安装并管理服务器插件;各 Agent 的启用状态在「个性化 → 插件」中配置" }, "adminUpdates": { "title": "应用更新", @@ -4956,7 +5223,7 @@ "pickDevice": "请先选择设备", "devicePlaceholder": "选择设备", "needsInstall": "需要安装容器", - "needsInstallDesc": "此主机使用容器 Android 后端。可一键拉取并启动容器(需本机已安装 Docker)。", + "needsInstallDesc": "此主机使用容器 Android 后端。可一键拉取并启动容器;若未安装 Docker 将自动尝试安装(失败时需手动安装)。", "install": "安装容器", "installing": "正在启动安装…", "installProgress": "正在安装 Android 容器…", @@ -4964,8 +5231,8 @@ "installFailed": "容器安装失败,请查看日志后重试", "installRetry": "重新安装", "installCancelHint": "已取消安装请求,服务端可能仍在继续安装,请稍后刷新状态。", - "installStep1": "确认主机已安装并可使用 Docker", - "installStep2": "点击「安装容器」,拉取并启动 Android 容器", + "installStep1": "点击「安装容器」;若主机未安装 Docker 将自动安装", + "installStep2": "等待拉取并启动 Android 容器", "installStep3": "安装完成后刷新状态,再点击「连接」", "needsDevice": "未连接设备", "needsDeviceDesc": "请启动 Android 模拟器或通过 USB 连接手机,然后刷新。", diff --git a/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx b/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx index 883f54b7..4aa302da 100644 --- a/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx +++ b/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx @@ -12,9 +12,7 @@ import { Collapse, Drawer, Empty, - Form, Input, - InputNumber, Modal, Popconfirm, Segmented, @@ -31,22 +29,13 @@ import { List, Package, Plus, - Settings2, Trash2, Upload, - Wrench, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { - pluginsApi, - type AgentPluginTool, - type AgentPluginsConfig, - type InstalledPlugin, - type PluginConfigField, -} from "../../../api/modules/plugins"; +import { pluginsApi, type InstalledPlugin } from "../../../api/modules/plugins"; import { ResizableTable } from "../../../components/ResizableTable"; import { CardSkeleton } from "../../../components/Skeleton"; -import { useAgent } from "../../../context/AgentContext"; import { useCardTableView } from "../../../hooks/useCardTableView"; import { ensureBuiltinToolRenderers, @@ -76,57 +65,10 @@ function statusTag(row: InstalledPlugin, t: (key: string) => string) { ); } -function buildPluginsConfig(tools: AgentPluginTool[]): AgentPluginsConfig { - const out: AgentPluginsConfig = {}; - for (const tool of tools) { - if (!out[tool.plugin_id]) out[tool.plugin_id] = { tools: {} }; - out[tool.plugin_id].tools![tool.name] = { - enabled: tool.enabled, - config: { ...tool.config }, - }; - } - return out; -} - -function renderConfigField(field: PluginConfigField) { - const common = { - label: field.label || field.name, - name: field.name, - rules: field.required - ? [{ required: true, message: field.label || field.name }] - : undefined, - extra: field.help, - }; - if (field.type === "password") { - return ( - - - - ); - } - if (field.type === "number") { - return ( - - - - ); - } - return ( - - - - ); -} - -/** Server-wide plugin install / uninstall list (+ per-agent tools in detail). */ +/** Server-wide plugin install, reload, enable, detail, and uninstall surface. */ export function InstalledPluginsPanel() { const { t } = useTranslation(); - const { activeAgentId } = useAgent(); const [plugins, setPlugins] = useState([]); - const [agentTools, setAgentTools] = useState([]); const [loading, setLoading] = useState(true); const [installOpen, setInstallOpen] = useState(false); const [installUrl, setInstallUrl] = useState(""); @@ -136,17 +78,9 @@ export function InstalledPluginsPanel() { const [overwrite, setOverwrite] = useState(false); const [reloading, setReloading] = useState(false); const [togglingId, setTogglingId] = useState(null); - const [toolSavingKey, setToolSavingKey] = useState(null); const [detail, setDetail] = useState(null); - const [configTool, setConfigTool] = useState(null); - const [form] = Form.useForm(); - const agentRef = useRef(activeAgentId); const { viewMode, setViewMode, showCardView } = useCardTableView("card"); - useEffect(() => { - agentRef.current = activeAgentId; - }, [activeAgentId]); - const fetchPlugins = useCallback(async () => { setLoading(true); try { @@ -163,30 +97,10 @@ export function InstalledPluginsPanel() { } }, [t]); - const fetchAgentTools = useCallback(async () => { - if (!activeAgentId) { - setAgentTools([]); - return; - } - const agentId = activeAgentId; - try { - const data = await pluginsApi.listAgentTools(agentId); - if (agentRef.current === agentId) { - setAgentTools(data.tools || []); - } - } catch (err) { - console.error(err); - } - }, [activeAgentId]); - useEffect(() => { void fetchPlugins(); }, [fetchPlugins]); - useEffect(() => { - void fetchAgentTools(); - }, [fetchAgentTools]); - const handleInstall = async () => { const url = installUrl.trim(); if (!url) return; @@ -197,7 +111,6 @@ export function InstalledPluginsPanel() { setInstallOpen(false); setInstallUrl(""); await fetchPlugins(); - await fetchAgentTools(); } catch (err) { message.error(apiErrorMessage(err, t("plugins.installFailed"), t)); } finally { @@ -218,7 +131,6 @@ export function InstalledPluginsPanel() { await pluginsApi.upload(next, overwrite); message.success(t("plugins.installSuccess")); await fetchPlugins(); - await fetchAgentTools(); } catch (err) { message.error(apiErrorMessage(err, t("plugins.installFailed"), t)); } finally { @@ -232,7 +144,6 @@ export function InstalledPluginsPanel() { await pluginsApi.reload(); message.success(t("plugins.reloadSuccess")); await fetchPlugins(); - await fetchAgentTools(); } catch (err) { message.error(apiErrorMessage(err, t("plugins.reloadFailed"), t)); } finally { @@ -246,7 +157,6 @@ export function InstalledPluginsPanel() { message.success(t("plugins.uninstallSuccess")); if (detail?.id === pluginId) setDetail(null); await fetchPlugins(); - await fetchAgentTools(); } catch (err) { message.error(apiErrorMessage(err, t("plugins.uninstallFailed"), t)); } @@ -271,7 +181,6 @@ export function InstalledPluginsPanel() { enabled ? t("plugins.enabledSuccess") : t("plugins.disabledSuccess"), ); await fetchPlugins(); - await fetchAgentTools(); } catch (err) { message.error(apiErrorMessage(err, t("plugins.enableFailed"), t)); } finally { @@ -279,63 +188,6 @@ export function InstalledPluginsPanel() { } }; - const toolKey = (tool: AgentPluginTool) => `${tool.plugin_id}:${tool.name}`; - - const persistTools = useCallback(async (nextTools: AgentPluginTool[]) => { - const agentId = agentRef.current; - if (!agentId) return; - await pluginsApi.patchAgentTools(agentId, buildPluginsConfig(nextTools)); - if (agentRef.current === agentId) setAgentTools(nextTools); - }, []); - - const handleToggleTool = async (tool: AgentPluginTool, enabled: boolean) => { - const key = toolKey(tool); - setToolSavingKey(key); - const next = agentTools.map((row) => - row.plugin_id === tool.plugin_id && row.name === tool.name - ? { ...row, enabled } - : row, - ); - try { - await persistTools(next); - message.success(t("plugins.saved")); - } catch (err) { - message.error(apiErrorMessage(err, t("plugins.saveFailed"), t)); - } finally { - setToolSavingKey(null); - } - }; - - const openConfig = (tool: AgentPluginTool) => { - setConfigTool(tool); - form.setFieldsValue(tool.config || {}); - }; - - const saveConfig = async () => { - if (!configTool) return; - const values = await form.validateFields(); - const next = agentTools.map((row) => - row.plugin_id === configTool.plugin_id && row.name === configTool.name - ? { ...row, config: values, enabled: true } - : row, - ); - setToolSavingKey(toolKey(configTool)); - try { - await persistTools(next); - message.success(t("plugins.saved")); - setConfigTool(null); - } catch (err) { - message.error(apiErrorMessage(err, t("plugins.saveFailed"), t)); - } finally { - setToolSavingKey(null); - } - }; - - const detailTools = - detail == null - ? [] - : agentTools.filter((tool) => tool.plugin_id === detail.id); - const columns = [ { title: t("plugins.colName"), @@ -735,118 +587,10 @@ export function InstalledPluginsPanel() { - -
-
-

- {t("plugins.colTools")} -

- {detailTools.length > 0 ? ( - - {detailTools.length} - - ) : null} -
- {!activeAgentId ? ( -
{t("plugins.noAgent")}
- ) : detail.enabled === false ? ( -
- {t("plugins.enablePluginFirst")} -
- ) : detailTools.length === 0 ? ( -
- {t("plugins.noToolsListed")} -
- ) : ( - <> -

- {t("plugins.detailToolsHint")} -

-
- {detailTools.map((tool) => { - const key = toolKey(tool); - const busy = toolSavingKey === key; - const hasConfig = (tool.config_fields?.length ?? 0) > 0; - return ( -
- - - -
-
- - {tool.name} - - {hasConfig ? ( - - {t("plugins.hasConfig")} - - ) : null} -
- {tool.description ? ( -
- {tool.description} -
- ) : null} -
-
- {hasConfig ? ( -
-
- ); - })} -
- - )} -
) : null} - setConfigTool(null)} - width={420} - destroyOnHidden - extra={ - - } - > -
- {configTool?.config_fields?.map(renderConfigField)} -
-
- d.kind === backend.kind); const accent = typeDef?.color ?? "#8c8c8c"; const icon = typeDef?.icon ?? null; + const group = storageKindGroup(backend.kind); const handleToggle = async (next: boolean) => { setToggling(true); @@ -58,9 +60,19 @@ export function StorageBackendCard({ }); await onSaved(); // Enabling a Docker sandbox should kick off image pull immediately. - if (next && backend.kind === "docker") { + if ( + next && + (backend.kind === "docker" || backend.kind === "opensandbox") + ) { setTesting(true); - const hide = message.loading(t("storage.dockerPulling"), 0); + const hide = message.loading( + t( + backend.kind === "opensandbox" + ? "storage.opensandboxInstalling" + : "storage.dockerPulling", + ), + 0, + ); try { const result = await request<{ ok: boolean; @@ -137,7 +149,9 @@ export function StorageBackendCard({ }); }; - const isConfigured = !!backend.access_key || !!backend.bucket; + const bucketField = typeDef?.fields.find((f) => f.key === "bucket"); + const isConfigured = + !!backend.access_key || !!backend.bucket || !!backend.endpoint; const primaryInfo = backend.bucket ?? backend.endpoint ?? "—"; const handleTest = async () => { @@ -187,7 +201,15 @@ export function StorageBackendCard({
{backend.name}
- {typeDef ? t(typeDef.nameKey) : backend.kind} + {typeDef ? t(typeDef.nameKey) : backend.kind} + {group ? ( + + {t(group.titleKey)} + + ) : null}
- {t("storage.bucketLabel")}: + {t(bucketField?.labelKey ?? "storage.bucketLabel")}: {primaryInfo !== "—" ? ( diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx index 3f9bd9b8..65a3a04f 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx @@ -120,6 +120,10 @@ export function StorageBackendDrawer({ form.setFieldValue("sandbox_scope", "agent"); form.setFieldValue("sandbox_prefix", "octop_sandbox"); } + if (kind === "opensandbox") { + form.setFieldValue("bucket", "python:3.12"); + form.setFieldValue("region", "http"); + } } const draft = loadFormDraft>(draftScope); if (draft) { @@ -199,9 +203,20 @@ export function StorageBackendDrawer({ message.success(t("storage.createSuccess", { name: backendName })); } - // New docker backends are enabled by default — start image pull right away. - if (values.kind === "docker" && backendId != null) { - const hide = message.loading(t("storage.dockerPulling"), 0); + // New docker / OpenSandbox backends are enabled by default — probe (and + // install optional SDK) immediately. + if ( + (values.kind === "docker" || values.kind === "opensandbox") && + backendId != null + ) { + const hide = message.loading( + t( + values.kind === "opensandbox" + ? "storage.opensandboxInstalling" + : "storage.dockerPulling", + ), + 0, + ); try { const result = await request<{ ok: boolean; diff --git a/dashboard/src/pages/Admin/Storage/StorageTypeCard.tsx b/dashboard/src/pages/Admin/Storage/StorageTypeCard.tsx index 34d0a7a8..0749a1d5 100644 --- a/dashboard/src/pages/Admin/Storage/StorageTypeCard.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageTypeCard.tsx @@ -5,8 +5,9 @@ import { memo } from "react"; import { CheckCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CatalogTypeCard } from "../../../components/CatalogTypeCard"; -import type { StorageTypeDef } from "./useStorageBackends"; +import { storageKindGroup, type StorageTypeDef } from "./useStorageBackends"; import cardStyles from "../../../components/CatalogTypeCard/catalogTypeCard.module.less"; +import styles from "./storage.module.less"; interface StorageTypeCardProps { typeDef: StorageTypeDef; @@ -20,6 +21,7 @@ export const StorageTypeCard = memo(function StorageTypeCard({ onClick, }: StorageTypeCardProps) { const { t } = useTranslation(); + const group = storageKindGroup(typeDef.kind); return ( + {t(group.titleKey)} + + ) : undefined + } onClick={() => onClick(typeDef)} configuredBadge={ isConfigured ? ( diff --git a/dashboard/src/pages/Admin/Storage/index.tsx b/dashboard/src/pages/Admin/Storage/index.tsx index cb447daa..a566a127 100644 --- a/dashboard/src/pages/Admin/Storage/index.tsx +++ b/dashboard/src/pages/Admin/Storage/index.tsx @@ -8,9 +8,11 @@ */ import { useMemo, useState } from "react"; import { Spin, Tabs } from "antd"; -import { HardDrive } from "lucide-react"; +import { HardDrive, LayoutGrid } from "lucide-react"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; +import { OctopEmptyMascot } from "../../../components/EmptyState"; +import StreamSetupGuide from "../../../components/StreamSetupGuide/StreamSetupGuide"; import { useStorageBackends, STORAGE_TYPE_DEFS, @@ -80,19 +82,33 @@ export default function AdminStoragePage() { } if (backends.length === 0) { return ( -
- -
{t("storage.emptyMyStorage")}
-
- {t("storage.emptyMyStorageHint")} -
- -
+ + } + title={t("storage.emptyGuideTitle")} + description={t("storage.emptyGuideDesc")} + steps={[ + { + label: t("storage.emptyGuideStepWhat"), + detail: t("storage.emptyGuideStepWhatDetail"), + }, + { + label: t("storage.emptyGuideStepHow"), + detail: t("storage.emptyGuideStepHowDetail"), + }, + { + label: t("storage.emptyGuideStepUse"), + detail: t("storage.emptyGuideStepUseDetail"), + }, + ]} + primaryAction={{ + label: t("storage.goToTypes"), + onClick: () => setActiveTab("types"), + icon: , + }} + /> ); } return ( diff --git a/dashboard/src/pages/Admin/Storage/storage.module.less b/dashboard/src/pages/Admin/Storage/storage.module.less index 23ae5606..e694b6bb 100644 --- a/dashboard/src/pages/Admin/Storage/storage.module.less +++ b/dashboard/src/pages/Admin/Storage/storage.module.less @@ -8,16 +8,41 @@ .cardGrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; padding: 20px 0 40px; - @media (max-width: 640px) { + @media (max-width: 767px) { grid-template-columns: 1fr; gap: 12px; } } +.emptyGuideMascot { + display: block; + width: 120px; + height: 120px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +/* ── Group chip (local / object storage / sandbox / database) ───── */ + +.groupChip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 0 7px; + border-radius: 9px; + font-size: 10px; + font-weight: 500; + line-height: 18px; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; +} + /* ── Toolbar row above grid ─────────────────────────────────────── */ .gridToolbar { @@ -104,12 +129,22 @@ } .backendCardKind { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; font-size: 11px; color: var(--fn-text-tertiary); margin-top: 2px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; + + > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } .backendCardInfo { diff --git a/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx b/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx index c390a7fa..14e2eef3 100644 --- a/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx +++ b/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx @@ -13,6 +13,7 @@ import { Archive, Package, Container, + Box, } from "lucide-react"; import { request } from "../../../api/request"; @@ -58,6 +59,7 @@ export const AGENT_RESOLVABLE_STORAGE_KINDS = new Set([ "shell", "postgres", "docker", + "opensandbox", ]); export function isAgentResolvableStorageKind(kind: string): boolean { @@ -286,6 +288,38 @@ export const STORAGE_TYPE_DEFS: StorageTypeDef[] = [ }, ], }, + { + kind: "opensandbox", + nameKey: "storage.kindOpensandbox", + descKey: "storage.descOpensandbox", + color: "#0f766e", + icon: , + fields: [ + { + key: "bucket", + labelKey: "storage.dockerImageLabel", + requiredMessageKey: "storage.pleaseEnterDockerImage", + required: true, + placeholder: "python:3.12", + }, + { + key: "endpoint", + labelKey: "storage.opensandboxDomainLabel", + placeholder: "localhost:8080", + }, + { + key: "secret_key", + labelKey: "storage.opensandboxApiKeyLabel", + secret: true, + placeholder: "sk-…", + }, + { + key: "region", + labelKey: "storage.opensandboxProtocolLabel", + placeholder: "http", + }, + ], + }, { kind: "postgres", nameKey: "storage.kindPostgres", @@ -371,6 +405,41 @@ export const STORAGE_KINDS = STORAGE_TYPE_DEFS.map((t) => ({ labelKey: t.nameKey, })); +export interface StorageTypeGroup { + id: string; + titleKey: string; + kinds: readonly string[]; +} + +export const STORAGE_TYPE_GROUPS: StorageTypeGroup[] = [ + { + id: "local", + titleKey: "storage.groupLocal", + kinds: ["filesystem", "shell"], + }, + { + id: "object", + titleKey: "storage.groupObject", + kinds: ["cos", "s3", "oss", "obs", "custom"], + }, + { + id: "sandbox", + titleKey: "storage.groupSandbox", + kinds: ["docker", "opensandbox"], + }, + { + id: "database", + titleKey: "storage.groupDatabase", + kinds: ["postgres"], + }, +]; + +/** Group a backend kind belongs to (local / object / sandbox / database). */ +export function storageKindGroup(kind: string): StorageTypeGroup | undefined { + const normalized = kind.toLowerCase(); + return STORAGE_TYPE_GROUPS.find((g) => g.kinds.includes(normalized)); +} + export interface UseStorageBackendsResult { backends: StorageBackendRow[]; loading: boolean; diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index 0129127a..d276f389 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -63,6 +63,7 @@ import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; import { authApi } from "../../../api/modules/auth"; import { useCardTableView } from "../../../hooks/useCardTableView"; +import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; import { formatServerDateTime } from "../../../utils/formatMessageTime"; import type { OctopAgent } from "../../../context/AgentContext"; @@ -766,6 +767,7 @@ function UserLoginLock({ export default function UsersListPanel() { const { t } = useTranslation(); const timeZone = useServerTimezone(); + const isMobile = useIsMobile(); const [agents, setAgents] = useState([]); const [agentsLoading, setAgentsLoading] = useState(true); const [rows, setRows] = useState([]); @@ -1190,7 +1192,7 @@ export default function UsersListPanel() { loading={loading} dataSource={filteredRows} pagination={false} - scroll={{ x: 960 }} + scroll={{ x: 1360 }} rowClassName={(row) => [ row.disabled ? styles.userTableRowDisabled : "", @@ -1203,6 +1205,7 @@ export default function UsersListPanel() { { title: t("adminUsers.colUsername"), width: 240, + fixed: isMobile ? undefined : "left", render: (_, row) => { const displayName = row.display_name?.trim() || row.username; return ( @@ -1332,6 +1335,7 @@ export default function UsersListPanel() { { title: t("adminUsers.colActions"), width: 120, + fixed: isMobile ? undefined : "right", render: (_, row) => ( diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index 8e5fa8d2..4cf233de 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -806,7 +806,7 @@ :global(.ant-table-container) { border: 1px solid var(--fn-border-primary); border-radius: var(--fn-radius-lg, 12px); - overflow: hidden; + // Do not set overflow:hidden — it breaks Ant Design sticky fixed columns. } :global(.ant-table-thead > tr > th) { diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx index 444b8e67..89840186 100644 --- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx +++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx @@ -122,7 +122,7 @@ type QrPhase = } | { phase: "dingtalk_success"; channelId: string } | { phase: "feishu_creating"; message: string } - | { phase: "feishu_qr"; qrToken: string } + | { phase: "feishu_qr"; qrUrl: string } | { phase: "feishu_progress"; message: string } | { phase: "feishu_done"; @@ -160,6 +160,7 @@ interface ChannelDrawerProps { } function FormItemForField({ field }: { field: ChannelField }) { + const { t } = useTranslation(); const Input1 = field.type === "password" ? Input.Password @@ -167,7 +168,12 @@ function FormItemForField({ field }: { field: ChannelField }) { ? Input.TextArea : Input; const rules: Rule[] = field.required - ? [{ required: true, message: `${field.label} 必填` }] + ? [ + { + required: true, + message: t("channels.fieldRequired", { label: field.label }), + }, + ] : []; if (field.type === "json") { rules.push({ @@ -176,7 +182,9 @@ function FormItemForField({ field }: { field: ChannelField }) { try { normalizeChannelFieldValue(field.name, value); } catch { - throw new Error(`${field.label} 必须是 JSON 对象`); + throw new Error( + t("channels.fieldMustBeJsonObject", { label: field.label }), + ); } }, }); @@ -511,16 +519,31 @@ export function ChannelDrawer({ } }, [open, resetQr]); - useEffect(() => { - resetQr(); - }, [selectedKind, resetQr]); - useEffect(() => { if (open && initialValues?.kind) { setSelectedKind(initialValues.kind); } }, [open, initialValues?.kind]); + // Only wipe an in-flight QR when the kind actually changes while the drawer + // is already open. A blanket `[selectedKind]` reset races the open-time + // kickoff: QR appears, kind sync/draft fires, resetQr hides it, and a second + // start often never gets a new qr_url. + const kindWhileOpenRef = useRef(null); + useEffect(() => { + if (!open) { + kindWhileOpenRef.current = null; + return; + } + if (kindWhileOpenRef.current === null) { + kindWhileOpenRef.current = selectedKind; + return; + } + if (kindWhileOpenRef.current === selectedKind) return; + kindWhileOpenRef.current = selectedKind; + resetQr(); + }, [open, selectedKind, resetQr]); + // Restore session draft after server/default values are applied. useEffect(() => { if (!open || loadingConfig || !draftScope) return; @@ -533,7 +556,8 @@ export function ChannelDrawer({ }, [open, loadingConfig, draftScope, form]); // ── QQ Bot Flow ──────────────────────────────────────────────────────── - const startQqQr = useCallback(async () => { + const startQqQr = async () => { + stopPolling(); setQrState({ phase: "loading" }); try { const res = await channelApi.qqQrcodeGenerate(agentId); @@ -570,10 +594,11 @@ export function ChannelDrawer({ reason: e instanceof Error ? e.message : String(e), }); } - }, [agentId, stopPolling, t]); + }; // ── WeCom Flow ───────────────────────────────────────────────────────── - const startWecomQr = useCallback(async () => { + const startWecomQr = async () => { + stopPolling(); setQrState({ phase: "loading" }); try { const res = await channelApi.wecomQrcodeGenerate(agentId); @@ -594,7 +619,10 @@ export function ChannelDrawer({ }); } else if (poll.status === "error") { stopPolling(); - setQrState({ phase: "error", reason: poll.reason ?? "扫码失败" }); + setQrState({ + phase: "error", + reason: poll.reason ?? t("channels.qrFailed"), + }); } } catch { // network error — keep polling @@ -607,10 +635,11 @@ export function ChannelDrawer({ reason: e instanceof Error ? e.message : String(e), }); } - }, [agentId, stopPolling]); + }; // ── WeChat Flow ───────────────────────────────────────────────────────── - const startWeixinQr = useCallback(async () => { + const startWeixinQr = async () => { + stopPolling(); setQrState({ phase: "loading" }); try { const res = await channelApi.weixinQrcodeGenerate(agentId); @@ -635,7 +664,10 @@ export function ChannelDrawer({ }); } else if (poll.status === "error") { stopPolling(); - setQrState({ phase: "error", reason: poll.message ?? "扫码失败" }); + setQrState({ + phase: "error", + reason: poll.message ?? t("channels.qrFailed"), + }); } } catch { // keep polling @@ -648,10 +680,11 @@ export function ChannelDrawer({ reason: e instanceof Error ? e.message : String(e), }); } - }, [agentId, stopPolling]); + }; // ── DingTalk Flow ─────────────────────────────────────────────────────── - const startDingtalkQr = useCallback(async () => { + const startDingtalkQr = async () => { + stopPolling(); setQrState({ phase: "loading" }); try { const res = await channelApi.dingtalkQrcodeGenerate(agentId); @@ -703,94 +736,70 @@ export function ChannelDrawer({ reason: e instanceof Error ? e.message : String(e), }); } - }, [agentId, onProvisioned, stopPolling, t]); + }; // ── Feishu Flow ───────────────────────────────────────────────────────── - const startFeishuCreator = useCallback( - async (platform: "feishu" | "lark" = "feishu") => { + const startFeishuCreator = async (platform: "feishu" | "lark" = "feishu") => { + stopPolling(); + setQrState({ + phase: "feishu_creating", + message: t("channels.feishuCreating"), + }); + try { + await channelApi.feishuBotCreatorStart(agentId, { platform }); + } catch (e: unknown) { setQrState({ - phase: "feishu_creating", - message: "启动飞书机器人创建流程...", + phase: "error", + reason: e instanceof Error ? e.message : String(e), }); + return; + } + const timer = setInterval(async () => { try { - await channelApi.feishuBotCreatorStart(agentId, { platform }); - } catch (e: unknown) { - setQrState({ - phase: "error", - reason: e instanceof Error ? e.message : String(e), - }); - return; - } - const timer = setInterval(async () => { - try { - const poll = await channelApi.feishuBotCreatorPoll(agentId); - let enteredProgress = false; - for (const ev of poll.events) { - if ( - ev.action === "progress" && - ev.message !== "Waiting for scan..." - ) { - enteredProgress = true; - setQrState({ phase: "feishu_progress", message: ev.message }); - } - if ( - ev.action === "log" && - ev.step === "login" && - (ev.message.includes("Scanned") || ev.level === "success") - ) { - enteredProgress = true; - setQrState({ - phase: "feishu_progress", - message: ev.message.includes("Scanned") - ? "已扫码,请在手机上确认登录" - : "登录成功,正在自动创建机器人,请稍候…", - }); - } - } - if (poll.qr_token && !enteredProgress) { - setQrState((prev) => - prev.phase === "feishu_progress" - ? prev - : { phase: "feishu_qr", qrToken: poll.qr_token! }, - ); - } - if (poll.status === "finished" && poll.app_id && poll.app_secret) { - stopPolling(); - const finishEvent = poll.events.find( - (e) => e.action === "finish" && e.level === "success", - ); - const data = (finishEvent?.data ?? {}) as Record; - setQrState({ - phase: "feishu_done", - appId: poll.app_id, - appSecret: poll.app_secret, - botName: data.bot_name as string | undefined, - manageUrl: data.manage_url as string | undefined, - }); - } else if (poll.status === "failed") { - stopPolling(); - const errEvent = poll.events.find( - (e) => e.action === "finish" && e.level === "error", - ); - setQrState({ - phase: "error", - reason: errEvent?.message ?? "飞书机器人创建失败", - }); - } - } catch { - // keep polling + const poll = await channelApi.feishuBotCreatorPoll(agentId); + if (poll.qr_url) { + setQrState((prev) => + prev.phase === "feishu_progress" || prev.phase === "feishu_done" + ? prev + : { phase: "feishu_qr", qrUrl: poll.qr_url! }, + ); } - }, 1500); - pollTimerRef.current = timer; - }, - [agentId, stopPolling], - ); + if (poll.status === "finished" && poll.app_id && poll.app_secret) { + stopPolling(); + const finishEvent = poll.events.find( + (e) => e.action === "finish" && e.level === "success", + ); + const data = (finishEvent?.data ?? {}) as Record; + setQrState({ + phase: "feishu_done", + appId: poll.app_id, + appSecret: poll.app_secret, + botName: data.bot_name as string | undefined, + manageUrl: data.manage_url as string | undefined, + }); + } else if (poll.status === "failed") { + stopPolling(); + const errEvent = poll.events.find( + (e) => e.action === "finish" && e.level === "error", + ); + setQrState({ + phase: "error", + reason: errEvent?.message ?? t("channels.feishuCreateFailed"), + }); + } + } catch { + // keep polling + } + }, 1500); + pollTimerRef.current = timer; + }; // ── YuanBao Flow ──────────────────────────────────────────────────────── - const startYuanbaoCreator = useCallback(async () => { + const startYuanbaoCreator = async () => { + stopPolling(); setQrState({ phase: "yuanbao_creating", - message: "启动元宝扫码绑定流程...", + message: t("channels.yuanbaoStarting"), }); try { await channelApi.yuanbaoBotCreatorStart(agentId, {}); @@ -833,7 +842,7 @@ export function ChannelDrawer({ ); setQrState({ phase: "error", - reason: errEvent?.message ?? "元宝绑定失败", + reason: errEvent?.message ?? t("channels.yuanbaoBindFailed"), }); } } catch { @@ -841,7 +850,49 @@ export function ChannelDrawer({ } }, 2000); pollTimerRef.current = timer; - }, [agentId, stopPolling]); + }; + + // Entering quick-config starts QR generation immediately (no extra click). + useEffect(() => { + if (!open || isEdit || loadingConfig) return; + if (configMode !== "quick" && selectedKind !== "weixin") return; + if (qrState.phase !== "idle") return; + // Wait until local kind matches the card that opened the drawer. + if (initialValues?.kind && selectedKind !== initialValues.kind) return; + + switch (selectedKind) { + case "qq": + void startQqQr(); + break; + case "wecom": + void startWecomQr(); + break; + case "weixin": + void startWeixinQr(); + break; + case "dingtalk": + void startDingtalkQr(); + break; + case "feishu": + void startFeishuCreator("feishu"); + break; + case "yuanbao": + void startYuanbaoCreator(); + break; + default: + break; + } + // start* omitted: recreated each render; idle-phase already one-shots. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + open, + isEdit, + loadingConfig, + configMode, + selectedKind, + qrState.phase, + initialValues?.kind, + ]); // ── Form ──────────────────────────────────────────────────────────────── const fields = CHANNEL_FIELDS[selectedKind]; @@ -1111,13 +1162,48 @@ export function ChannelDrawer({ }; // ── QR Panels ─────────────────────────────────────────────────────────── + function renderQrSteps(step1: string, step2: string, step3: string) { + return ( +
+ + 1 + {step1} + + + + 2 + {step2} + + + + 3 + {step3} + +
+ ); + } + + function renderQrLoading(step1: string, step2: string, step3: string) { + return ( +
+ {renderQrSteps(step1, step2, step3)} +
+
+ +
+
+

{t("channels.qrLoading")}

+
+ ); + } + function renderQqPanel() { const s = qrState; - if (s.phase === "loading") - return ( -
- -
+ if (s.phase === "loading" || s.phase === "idle") + return renderQrLoading( + t("channels.qqQrStep1"), + t("channels.qqQrStep2"), + t("channels.qqQrStep3"), ); if (s.phase === "qq_success") { const retrySave = () => @@ -1146,30 +1232,23 @@ export function ChannelDrawer({ if (s.phase === "qq_ready") { return (
-
- - 1 - {t("channels.qqQrStep1")} - - - - 2 - {t("channels.qqQrStep2")} - - - - 3 - {t("channels.qqQrStep3")} - -
+ {renderQrSteps( + t("channels.qqQrStep1"), + t("channels.qqQrStep2"), + t("channels.qqQrStep3"), + )}

{t("channels.qqQrScanHint")}

-
); @@ -1183,46 +1262,21 @@ export function ChannelDrawer({ style={{ width: "100%", marginBottom: 12 }} />
); } - return ( -
-
-
- 1 - {t("channels.qqQrIntro1")} -
-
- 2 - {t("channels.qqQrIntro2")} -
-
- 3 - {t("channels.qqQrIntro3")} -
-
- -
- ); + return null; } function renderWecomPanel() { const s = qrState; - if (s.phase === "loading") - return ( -
- -
+ if (s.phase === "loading" || s.phase === "idle") + return renderQrLoading( + t("channels.qrStep1"), + t("channels.qrStep2"), + t("channels.qrStep3"), ); if (s.phase === "wecom_success") { const retrySave = () => @@ -1236,7 +1290,7 @@ export function ChannelDrawer({
@@ -1247,26 +1301,22 @@ export function ChannelDrawer({ if (s.phase === "wecom_ready") { return (
-
- - 1打开企业微信 - - - - 2扫码注册 AI 机器人 - - - - 3确认绑定 - -
+ {renderQrSteps( + t("channels.qrStep1"), + t("channels.qrStep2"), + t("channels.qrStep3"), + )}
-

扫码后自动跳转下一步

-
@@ -1286,41 +1336,16 @@ export function ChannelDrawer({
); } - return ( -
-
-
- 1 - 点击下方按钮,生成企业微信 AI 机器人注册二维码 -
-
- 2用企业微信 App - 扫码,完成机器人注册 -
-
- 3 - 凭据自动填入并保存 -
-
- -
- ); + return null; } function renderWeixinPanel() { const s = qrState; - if (s.phase === "loading") - return ( -
- -
+ if (s.phase === "loading" || s.phase === "idle") + return renderQrLoading( + t("channels.weixinQrStep1"), + t("channels.weixinQrStep2"), + t("channels.weixinQrStep3"), ); if (s.phase === "weixin_success") { const weixinConfig = mergeDisplayConfig({ @@ -1342,8 +1367,8 @@ export function ChannelDrawer({
{renderQrAutoSaveStatus(retrySave)} @@ -1353,26 +1378,22 @@ export function ChannelDrawer({ if (s.phase === "weixin_ready") { return (
-
- - 1打开微信 App - - - - 2扫码登录 - - - - 3手机确认 - -
+ {renderQrSteps( + t("channels.weixinQrStep1"), + t("channels.weixinQrStep2"), + t("channels.weixinQrStep3"), + )}
-

使用微信扫码登录个人账号

-
@@ -1392,40 +1413,16 @@ export function ChannelDrawer({
); } - return ( -
-
-
- 1 - 点击按钮生成微信登录二维码 -
-
- 2微信扫码并在手机确认登录 -
-
- 3登录成功后自动保存配置 -
-
- -
- ); + return null; } function renderDingtalkPanel() { const s = qrState; - if (s.phase === "loading") { - return ( -
- -

{t("channels.dingtalkQrLoading")}

-
+ if (s.phase === "loading" || s.phase === "idle") { + return renderQrLoading( + t("channels.dingtalkQrStep1"), + t("channels.dingtalkQrStep2"), + t("channels.dingtalkQrStep3"), ); } if (s.phase === "dingtalk_success") { @@ -1442,22 +1439,11 @@ export function ChannelDrawer({ if (s.phase === "dingtalk_ready") { return (
-
- - 1 - {t("channels.dingtalkQrStep1")} - - - - 2 - {t("channels.dingtalkQrStep2")} - - - - 3 - {t("channels.dingtalkQrStep3")} - -
+ {renderQrSteps( + t("channels.dingtalkQrStep1"), + t("channels.dingtalkQrStep2"), + t("channels.dingtalkQrStep3"), + )}
@@ -1469,7 +1455,11 @@ export function ChannelDrawer({

{t("channels.dingtalkUserCode")}: {s.userCode}

-
@@ -1489,77 +1479,48 @@ export function ChannelDrawer({
); } - return ( -
-
-
- 1 - {t("channels.dingtalkQrIntro1")} -
-
- 2 - {t("channels.dingtalkQrIntro2")} -
-
- 3 - {t("channels.dingtalkQrIntro3")} -
-
- -
- ); + return null; } function renderFeishuPanel() { const s = qrState; - if (s.phase === "feishu_creating" || s.phase === "feishu_progress") { + const feishuPending = + s.phase === "idle" || + s.phase === "loading" || + s.phase === "feishu_creating" || + s.phase === "feishu_progress"; + if (feishuPending || s.phase === "feishu_qr") { return (
- -

- {s.message} -

-
- ); - } - if (s.phase === "feishu_qr") { - const qrContent = JSON.stringify({ qrlogin: { token: s.qrToken } }); - return ( -
-
- - 1打开飞书 App - - - - 2扫码登录 - - - - 3自动创建机器人 - -
+ {renderQrSteps( + t("channels.feishuQrStep1"), + t("channels.feishuQrStep2"), + t("channels.feishuQrStep3"), + )}
- + {s.phase === "feishu_qr" ? ( + + ) : ( + + )}

- 扫码后将自动完成机器人创建和配置(约 1-2 分钟) + {s.phase === "feishu_qr" + ? t("channels.feishuQrScanHint") + : t("channels.qrLoading")}

+
); } @@ -1578,7 +1539,11 @@ export function ChannelDrawer({
{s.manageUrl && ( @@ -1588,7 +1553,7 @@ export function ChannelDrawer({ rel="noopener noreferrer" style={{ fontSize: 13, marginBottom: 12, display: "block" }} > - 前往管理后台 → + {t("channels.feishuManageBot")} )} {renderQrAutoSaveStatus(retrySave)} @@ -1603,78 +1568,55 @@ export function ChannelDrawer({ message={s.reason} style={{ width: "100%", marginBottom: 12 }} /> - +
); } - return ( -
-
-
- 1 - 点击按钮,启动自动创建流程 -
-
- 2扫码登录飞书账号 -
-
- 3 - 自动完成机器人注册和权限配置 -
-
- -
+ return renderQrLoading( + t("channels.feishuQrStep1"), + t("channels.feishuQrStep2"), + t("channels.feishuQrStep3"), ); } function renderYuanbaoPanel() { const s = qrState; - if (s.phase === "yuanbao_creating" || s.phase === "yuanbao_progress") { - return ( -
- -

- {s.message} -

-
+ if ( + s.phase === "idle" || + s.phase === "loading" || + s.phase === "yuanbao_creating" || + s.phase === "yuanbao_progress" + ) { + return renderQrLoading( + t("channels.yuanbaoQrStep1"), + t("channels.yuanbaoQrStep2"), + t("channels.yuanbaoQrStep3"), ); } if (s.phase === "yuanbao_scan") { const qrValue = s.scanUrl ?? s.scanCode; return (
-
- - 1打开元宝 App - - - - 2扫码绑定 - - - - 3确认授权 - -
+ {renderQrSteps( + t("channels.yuanbaoQrStep1"), + t("channels.yuanbaoQrStep2"), + t("channels.yuanbaoQrStep3"), + )}
-

扫码后在元宝 App 确认绑定

+

{t("channels.yuanbaoQrScanHint")}

+
); } @@ -1695,7 +1637,7 @@ export function ChannelDrawer({
{renderQrAutoSaveStatus(retrySave)} @@ -1710,33 +1652,16 @@ export function ChannelDrawer({ message={s.reason} style={{ width: "100%", marginBottom: 12 }} /> - +
); } - return ( -
-
-
- 1点击按钮,获取元宝扫码 -
-
- 2在元宝 App - 扫码并确认授权 -
-
- 3凭据自动填入并保存 -
-
- -
+ return renderQrLoading( + t("channels.yuanbaoQrStep1"), + t("channels.yuanbaoQrStep2"), + t("channels.yuanbaoQrStep3"), ); } @@ -1756,7 +1681,7 @@ export function ChannelDrawer({ )} {isEdit - ? `${kindLabel} ${t("channels.channelSettings")}` + ? t("channels.channelSettingsNamed", { kind: kindLabel }) : t("channels.createChannel")}
@@ -1833,8 +1758,8 @@ export function ChannelDrawer({ value={configMode} onChange={(v) => setConfigMode(v as "quick" | "manual")} options={[ - { label: "快捷配置(扫码)", value: "quick" }, - { label: "手动填写", value: "manual" }, + { label: t("channels.quickConfig"), value: "quick" }, + { label: t("channels.manualConfig"), value: "manual" }, ]} /> )} @@ -1868,7 +1793,7 @@ export function ChannelDrawer({ rel="noopener noreferrer" className={styles.bannerLink} > - 前往获取凭据 + {t("channels.getCredentials")}
@@ -1901,7 +1826,7 @@ export function ChannelDrawer({ { @@ -1914,12 +1839,14 @@ export function ChannelDrawer({ Array.isArray(parsed) ) { return Promise.reject( - new Error("必须是 JSON 对象"), + new Error(t("channels.jsonMustBeObject")), ); } return Promise.resolve(); } catch { - return Promise.reject(new Error("非法 JSON")); + return Promise.reject( + new Error(t("channels.invalidJson")), + ); } }, }, diff --git a/dashboard/src/pages/Agent/Channels/index.module.less b/dashboard/src/pages/Agent/Channels/index.module.less index 8f8b69e2..0e4986cb 100644 --- a/dashboard/src/pages/Agent/Channels/index.module.less +++ b/dashboard/src/pages/Agent/Channels/index.module.less @@ -612,6 +612,15 @@ } .qrFrame { + box-sizing: content-box; + display: flex; + align-items: center; + justify-content: center; + width: 200px; + height: 200px; + min-width: 200px; + min-height: 200px; + flex-shrink: 0; padding: 8px; background: #fff; border-radius: 10px; @@ -635,6 +644,7 @@ gap: 6px; width: 100%; justify-content: center; + margin-bottom: 8px; } .qrStep { @@ -647,6 +657,7 @@ border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.07)); padding: 4px 10px 4px 5px; border-radius: 999px; + white-space: nowrap; } .qrDot { diff --git a/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx b/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx index 17eb9f83..174b7091 100644 --- a/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx @@ -1,35 +1,22 @@ import { memo } from "react"; -import { Switch } from "antd"; import { useTranslation } from "react-i18next"; -import type { - ConnectorCatalogEntry, - ConnectorInstance, -} from "../../../api/modules/connectors"; +import type { ConnectorCatalogEntry } from "../../../api/modules/connectors"; import { ConnectorLogo, connectorAccent } from "./connectorDefs"; import styles from "./index.module.less"; interface ConnectorCardProps { entry: ConnectorCatalogEntry; - instance: ConnectorInstance | null; - onConfigure: ( - entry: ConnectorCatalogEntry, - instance: ConnectorInstance | null, - ) => void; - onToggleEnabled: (instance: ConnectorInstance, enabled: boolean) => void; + onConfigure: (entry: ConnectorCatalogEntry, instance: null) => void; } export const ConnectorCard = memo(function ConnectorCard({ entry, - instance, onConfigure, - onToggleEnabled, }: ConnectorCardProps) { const { t } = useTranslation(); const accent = connectorAccent(entry); const disabled = entry.phase !== "available"; - const configured = instance != null && instance.has_credentials; - const enabled = configured && instance?.status === "active"; return (
!disabled && onConfigure(entry, instance)} + onClick={() => !disabled && onConfigure(entry, null)} role="button" tabIndex={disabled ? -1 : 0} onKeyDown={(e) => - e.key === "Enter" && !disabled && onConfigure(entry, instance) + e.key === "Enter" && !disabled && onConfigure(entry, null) } >
@@ -58,23 +45,8 @@ export const ConnectorCard = memo(function ConnectorCard({ {!disabled ? (
- {configured - ? t("connectors.clickToManage", "点击管理连接") - : t("connectors.clickToConnect", "点击连接")} + {t("connectors.clickToConnect", "点击连接")}
- {configured && instance && ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - onToggleEnabled(instance, checked)} - /> -
- )}
) : (
diff --git a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx index 9ed6cba7..7337c59f 100644 --- a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx @@ -1,6 +1,6 @@ -import { App, Button } from "antd"; +import { App, Switch, Tag, Tooltip } from "antd"; -import { Activity, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -8,34 +8,33 @@ import { type ConnectorCatalogEntry, type ConnectorInstance, } from "../../../api/modules/connectors"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { ConnectorLogo, connectorAccent } from "./connectorDefs"; import styles from "./index.module.less"; interface ConnectorInstanceCardProps { instance: ConnectorInstance; catalogEntry: ConnectorCatalogEntry | undefined; - onDeleted: () => void | Promise; + onEdit: (instance: ConnectorInstance) => void; + onChanged: () => void | Promise; } export function ConnectorInstanceCard({ instance, catalogEntry, - onDeleted, + onEdit, + onChanged, }: ConnectorInstanceCardProps) { const { t } = useTranslation(); const { modal, message } = App.useApp(); + const user = useCurrentUser(); const accent = catalogEntry ? connectorAccent(catalogEntry) : "#8c8c8c"; - - const handleTest = async () => { - try { - const r = await connectorsApi.testInstance(instance.instance_id); - if (r.ok) message.success(t("connectors.testOk", "连接正常")); - else message.error(r.error ?? t("connectors.testFailed", "测试失败")); - } catch (e) { - console.error(e); - message.error(t("connectors.testFailed", "测试失败")); - } - }; + const isOwner = user?.id === instance.owner_user_id; + const ownerLabel = + instance.owner_display_name || instance.owner_username || ""; + const editable = + instance.can_manage && + (catalogEntry != null || (instance.kind === "custom-mcp" && isOwner)); const handleDelete = () => { modal.confirm({ @@ -46,44 +45,106 @@ export function ConnectorInstanceCard({ onOk: async () => { await connectorsApi.deleteInstance(instance.instance_id); message.success(t("connectors.deleteSuccess", "已删除")); - await onDeleted(); + await onChanged(); }, }); }; + const handleToggle = async (enabled: boolean) => { + try { + await connectorsApi.patchInstance(instance.instance_id, { + status: enabled ? "active" : "disabled", + }); + message.success( + enabled + ? t("connectors.enableSuccess", "已启用") + : t("connectors.disableSuccess", "已停用"), + ); + await onChanged(); + } catch (error) { + console.error(error); + message.error(t("connectors.toggleFailed", "更新失败")); + } + }; + return (
editable && onEdit(instance)} + role={editable ? "button" : undefined} + tabIndex={editable ? 0 : -1} + onKeyDown={(e) => e.key === "Enter" && editable && onEdit(instance)} > -
-
- -
-
-
{instance.display_name}
-
- {catalogEntry?.name ?? instance.kind} +
+
+
+ +
+
{instance.display_name}
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {instance.shared ? ( + + {isOwner || !ownerLabel + ? t("connectors.sharedBadge", "共享") + : t("connectors.sharedFrom", { + name: ownerLabel, + defaultValue: `来自 ${ownerLabel}`, + })} + + ) : null} + {instance.can_manage ? ( + void handleToggle(enabled)} + /> + ) : null}
+ +
+ {instance.description || + catalogEntry?.description || + catalogEntry?.name || + instance.kind} +
-
- - + +
+ ) : null}
); diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx index d17a9bdb..6b2c9813 100644 --- a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx @@ -31,6 +31,7 @@ interface CustomMcpServerCardProps { onProbe: () => void; onAuthorize?: () => void; onDefaultOpenChange?: (defaultOpen: boolean) => void; + onSharedChange?: (shared: boolean) => void; } export function CustomMcpServerCard({ @@ -46,6 +47,7 @@ export function CustomMcpServerCard({ onProbe, onAuthorize, onDefaultOpenChange, + onSharedChange, }: CustomMcpServerCardProps) { const { t } = useTranslation(); const { modal } = App.useApp(); @@ -308,9 +310,29 @@ export function CustomMcpServerCard({ )}
- + +
+ { + if (onSharedChange) { + onSharedChange(checked); + return; + } + onUpdate(card.key, { shared: checked }); + }} + /> + + {t( + "connectors.sharedHint", + "共享后其他用户可以选择使用,但不能查看或修改配置。", + )} + +
+
+ +
+
{ + if (!focusServerName || loading) return; + setMode("visual"); + setCards((prev) => + prev.map((card) => + card.name.trim() === focusServerName + ? { ...card, collapsed: false } + : card, + ), + ); + }, [focusServerName, loading]); + const persistServerPatch = async ( card: ServerCardState, - apiPatch: { enabled?: boolean; default_open?: boolean }, + apiPatch: { + enabled?: boolean; + default_open?: boolean; + shared?: boolean; + }, localPatch: Partial, ) => { const optimisticCards = cards.map((item) => @@ -751,6 +771,9 @@ export function CustomMcpTab() { { defaultOpen }, ); }} + onSharedChange={(shared) => { + void persistServerPatch(card, { shared }, { shared }); + }} onRemove={() => handleRemove(card.key)} onProbe={() => void handleProbe(card)} onAuthorize={() => void handleOAuth(card)} diff --git a/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts b/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts index bfc3850f..9bbc835e 100644 --- a/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts +++ b/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts @@ -18,6 +18,7 @@ export interface ServerCardState { envText: string; enabled: boolean; defaultOpen: boolean; + shared: boolean; collapsed: boolean; oauthConfigured: boolean; oauthExpiresAt?: number; @@ -125,6 +126,7 @@ export function serversToCards(servers: CustomMcpServers): ServerCardState[] { envText: envToText(spec.env), enabled: spec.enabled !== false, defaultOpen: spec.default_open === true, + shared: spec.shared === true, collapsed: true, oauthConfigured: spec.oauth?.configured === true, oauthExpiresAt: spec.oauth?.expires_at, @@ -152,6 +154,9 @@ export function cardsToServers(cards: ServerCardState[]): CustomMcpServers { if (card.defaultOpen) { spec.default_open = true; } + if (card.shared) { + spec.shared = true; + } if (card.transport === "streamable_http") { spec.url = card.url.trim(); const headers = parseHeadersText(card.headersText); @@ -243,6 +248,7 @@ export function newCard( envText: "", enabled: true, defaultOpen: false, + shared: false, collapsed: false, oauthConfigured: false, }; diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index 032e1b52..f6f5ecb4 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -40,6 +40,15 @@ gap: 16px; } +.emptyGuideMascot { + display: block; + width: 120px; + height: 120px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + /* ── Type Card (service catalog) ───────────────────────────────── */ .typeCard { @@ -154,16 +163,6 @@ flex-shrink: 0; } -.typeCardSwitch { - display: flex; - align-items: center; - flex-shrink: 0; -} - -.typeCardSwitchLabel { - display: none; -} - .typeCardIcon { width: 40px; height: 40px; @@ -208,67 +207,45 @@ /* ── Instance Card (my connections) ────────────────────────────── */ -.instanceCard { - background: var(--fn-bg-primary); - border: 2px solid var(--fn-card-border-normal); - border-radius: var(--fn-radius-lg); - padding: 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - transition: - border-color 0.2s, - box-shadow 0.2s; - - &:hover { - border-color: color-mix( - in srgb, - var(--connector-accent, var(--fn-color-brand)) 40%, - var(--fn-card-border-normal) - ); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); - } +.instanceCardTag { + flex-shrink: 0; + margin-inline-end: 0; } -.instanceCardMain { +.instanceCardHeaderActions { display: flex; align-items: center; - gap: 12px; - min-width: 0; + gap: 8px; + flex-shrink: 0; } -.instanceCardIcon { - width: 40px; - height: 40px; - border-radius: var(--fn-radius-md); +.instanceCardActions { display: flex; align-items: center; - justify-content: center; + gap: 6px; flex-shrink: 0; } -.instanceCardMeta { - min-width: 0; -} - -.instanceCardName { - font-weight: 600; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.instanceCardKind { - font-size: 12px; +.instanceCardDelBtn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: var(--fn-radius-sm); + border: 1px solid var(--fn-border-primary); + background: transparent; + cursor: pointer; color: var(--fn-text-tertiary); -} + transition: + color 0.15s, + border-color 0.15s, + background 0.15s; -.instanceCardActions { - display: flex; - gap: 8px; - flex-shrink: 0; + &:hover { + color: #ff4d4f; + border-color: #ff4d4f; + background: color-mix(in srgb, #ff4d4f 8%, transparent); + } } .connectorLogo { diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index 2dc8258a..5453bb55 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -9,6 +9,8 @@ import { Copy, Download, ExternalLink, + Plug, + Plus, RefreshCw, Sparkles, } from "lucide-react"; @@ -16,6 +18,8 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import PageShell from "../../../layouts/PageShell"; +import StreamSetupGuide from "../../../components/StreamSetupGuide/StreamSetupGuide"; +import { OctopEmptyMascot } from "../../../components/EmptyState/OctopEmptyMascot"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { userCan } from "../../../utils/permissions"; import { apiErrorMessage } from "../../../utils/apiError"; @@ -36,6 +40,7 @@ import { type FeishuUserAuthStartResult, } from "../../../api/modules/connectors"; import { ConnectorCard } from "./ConnectorCard"; +import { ConnectorInstanceCard } from "./ConnectorInstanceCard"; import { CustomMcpTab } from "./CustomMcpTab"; import { INLINE_CREDENTIAL_GUIDE_KINDS, @@ -137,15 +142,19 @@ function previewToFormValues( if (!detail) { return { display_name: entry.name, + description: entry.description, mail_provider: "qq", default_open: false, + shared: false, }; } const preview = detail.credentials_preview ?? {}; const values: Record = { display_name: detail.display_name || entry.name, + description: detail.description || entry.description, default_open: detail.default_open === true || detail.config?.default_open === true, + shared: detail.shared === true, }; if (preview.email) values.email = preview.email; if (preview.mail_provider) values.mail_provider = preview.mail_provider; @@ -353,7 +362,12 @@ function ConnectorConfigDrawer({ setFeishuAuthNeedsReauth(false); setFeishuRefreshExpiresAt(null); form.resetFields(); - form.setFieldsValue({ display_name: entry.name, default_open: false }); + form.setFieldsValue({ + display_name: entry.name, + description: entry.description, + default_open: false, + shared: false, + }); void connectorsApi .authInfo(entry.kind) @@ -403,6 +417,7 @@ function ConnectorConfigDrawer({ .catch(() => { form.setFieldsValue({ display_name: instance.display_name || entry.name, + description: instance.description || entry.description, default_open: instance.default_open === true, }); applyConnectorDraft(); @@ -869,12 +884,24 @@ function ConnectorConfigDrawer({ return; } - await connectorsApi.createInstance({ - kind: entry.kind, - display_name: String(values.display_name || entry.name), - credentials, - default_open: values.default_open === true, - }); + if (instance) { + await connectorsApi.patchInstance(instance.instance_id, { + display_name: String(values.display_name || entry.name), + description: String(values.description || entry.description), + credentials, + default_open: values.default_open === true, + shared: values.shared === true, + }); + } else { + await connectorsApi.createInstance({ + kind: entry.kind, + display_name: String(values.display_name || entry.name), + description: String(values.description || entry.description), + credentials, + default_open: values.default_open === true, + shared: values.shared === true, + }); + } clearFormDraft(draftScope); message.success(t("connectors.createSuccess", "连接器已创建")); onSaved(); @@ -883,6 +910,7 @@ function ConnectorConfigDrawer({ console.error(e); form.setFieldsValue({ display_name: entry.name, + description: entry.description, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: tokens.expires_at, @@ -1077,12 +1105,24 @@ function ConnectorConfigDrawer({ setSaving(true); try { const payload = buildCredentials(entry, values); - await connectorsApi.createInstance({ - kind: entry.kind, - display_name: values.display_name as string, - credentials: payload, - default_open: values.default_open === true, - }); + if (instance) { + await connectorsApi.patchInstance(instance.instance_id, { + display_name: values.display_name as string, + description: values.description as string, + credentials: payload, + default_open: values.default_open === true, + shared: values.shared === true, + }); + } else { + await connectorsApi.createInstance({ + kind: entry.kind, + display_name: values.display_name as string, + description: values.description as string, + credentials: payload, + default_open: values.default_open === true, + shared: values.shared === true, + }); + } message.success( hasStoredCredentials ? t("connectors.saveSuccess", "连接器已保存") @@ -1129,11 +1169,11 @@ function ConnectorConfigDrawer({ hasStoredCredentials ? t("connectors.editConnection", { name: entry.name, - defaultValue: `配置 ${entry.name}`, + defaultValue: `编辑 ${entry.name} 连接器`, }) - : t("connectors.configureConnection", { + : t("connectors.createConnection", { name: entry.name, - defaultValue: `配置 ${entry.name}`, + defaultValue: `创建 ${entry.name} 连接器`, }) } open={open} @@ -1445,6 +1485,18 @@ function ConnectorConfigDrawer({ > + + + {entry.auth_kind === "custom_fields" && (entry.credential_fields ?? []).map((field) => { @@ -1957,9 +2009,21 @@ function ConnectorConfigDrawer({ )} + + + + ("builtin"); + const [activeTab, setActiveTab] = useState<"enabled" | "builtin" | "custom">( + "enabled", + ); const [drawerEntry, setDrawerEntry] = useState( null, ); const [drawerInstance, setDrawerInstance] = useState(null); + const [customFocusServerName, setCustomFocusServerName] = useState< + string | null + >(null); const { catalog, instances, loading, refresh } = useConnectorInstances(); - const instanceByKind = useMemo(() => { - const map = new Map(); - for (const inst of instances) { - if (!map.has(inst.kind)) { - map.set(inst.kind, inst); - } - } - return map; - }, [instances]); - const configuredCount = useMemo(() => { - let count = 0; - for (const entry of catalog) { - const inst = instanceByKind.get(entry.kind); - if (inst?.has_credentials) count += 1; - } - return count; - }, [catalog, instanceByKind]); + return instances.filter((instance) => instance.has_credentials).length; + }, [instances]); useEffect(() => { const oauthState = searchParams.get("oauth_state"); @@ -2092,6 +2146,7 @@ export default function ConnectorsPage() { await connectorsApi.createInstance({ kind: entry.kind, display_name: entry.name, + description: entry.description, credentials, default_open: false, }); @@ -2114,26 +2169,6 @@ export default function ConnectorsPage() { [], ); - const handleToggleEnabled = useCallback( - async (instance: ConnectorInstance, enabled: boolean) => { - try { - await connectorsApi.patchInstance(instance.instance_id, { - status: enabled ? "active" : "disabled", - }); - await refresh(); - message.success( - enabled - ? t("connectors.enableSuccess", "已启用") - : t("connectors.disableSuccess", "已停用"), - ); - } catch (e) { - console.error(e); - message.error(t("connectors.toggleFailed", "更新失败")); - } - }, - [refresh, t], - ); - const handleSaved = useCallback(async () => { await refresh(); notifyConnectorsChanged(); @@ -2150,6 +2185,15 @@ export default function ConnectorsPage() { subtitle={t("pageShell.connectors.subtitle")} tabBar={
+ @@ -2172,7 +2219,92 @@ export default function ConnectorsPage() { } > {activeTab === "custom" ? ( - + + ) : activeTab === "enabled" ? ( + loading ? ( +
+ +
+ ) : instances.length === 0 ? ( + + } + title={t("connectors.emptyGuideTitle")} + description={t("connectors.emptyGuideDesc")} + steps={[ + { + label: t("connectors.emptyGuideStepWhat"), + detail: t("connectors.emptyGuideStepWhatDetail"), + }, + { + label: t("connectors.emptyGuideStepHow"), + detail: t("connectors.emptyGuideStepHowDetail"), + }, + { + label: t("connectors.emptyGuideStepShare"), + detail: t("connectors.emptyGuideStepShareDetail"), + }, + ]} + primaryAction={{ + label: t("connectors.emptyGuideBrowseBuiltin"), + onClick: () => setActiveTab("builtin"), + icon: , + }} + secondaryAction={{ + label: t("connectors.emptyGuideAddCustom"), + onClick: () => { + setCustomFocusServerName(null); + setActiveTab("custom"); + }, + icon: , + type: "default", + }} + /> + ) : ( + <> +
+ + {t("connectors.enabledSummary", { + count: instances.length, + defaultValue: "已启用 {{count}} 个连接器实例", + })} + + +
+
+ {instances.map((instance) => ( + entry.kind === instance.kind, + )} + onEdit={(item) => { + if (item.kind === "custom-mcp") { + setCustomFocusServerName(item.mcp_server_name); + setActiveTab("custom"); + return; + } + const entry = catalog.find((row) => row.kind === item.kind); + if (entry) handleConfigure(entry, item); + }} + onChanged={handleSaved} + /> + ))} +
+ + ) ) : ( <>
@@ -2202,11 +2334,7 @@ export default function ConnectorsPage() { - void handleToggleEnabled(inst, enabled) - } /> ))}
diff --git a/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.module.less b/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.module.less new file mode 100644 index 00000000..d3d859ed --- /dev/null +++ b/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.module.less @@ -0,0 +1,46 @@ +.panel { + height: 100%; + overflow: auto; + padding: 4px; +} + +.loading { + display: flex; + min-height: 240px; + align-items: center; + justify-content: center; +} + +.hint { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 16px; + padding: 10px 12px; + font-size: 13px; + line-height: 1.6; + color: var(--fn-text-secondary); + background: var(--fn-bg-info-light); + border: 1px solid var(--fn-color-info-border); + border-radius: var(--fn-radius-md); +} + +.hintIcon { + flex-shrink: 0; + margin-top: 2px; + color: var(--fn-color-info); +} + +.detailDescription { + margin: 16px 0; + color: var(--text-secondary); +} + +.pluginSwitch { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); +} diff --git a/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.tsx b/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.tsx new file mode 100644 index 00000000..afd31532 --- /dev/null +++ b/dashboard/src/pages/Agent/Personalization/components/AgentPluginsPanel.tsx @@ -0,0 +1,369 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Drawer, + Empty, + Form, + Input, + InputNumber, + Spin, + Switch, + Tag, +} from "antd"; +import { Info, Settings2, Wrench } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + pluginsApi, + type AgentPlugin, + type AgentPluginTool, + type AgentPluginsConfig, + type PluginConfigField, +} from "../../../../api/modules/plugins"; +import { PluginIconView } from "../../../Admin/Plugins/PluginIconView"; +import { message } from "../../../../utils/antdMessage"; +import { apiErrorMessage } from "../../../../utils/apiError"; +import pluginStyles from "../../../Admin/Plugins/index.module.less"; +import styles from "./AgentPluginsPanel.module.less"; + +interface AgentPluginsPanelProps { + agentId: string | null; +} + +function toolsConfig(tools: AgentPluginTool[]): AgentPluginsConfig { + const plugins: AgentPluginsConfig = {}; + for (const tool of tools) { + const entry = (plugins[tool.plugin_id] ??= { tools: {} }); + entry.tools![tool.name] = { + enabled: tool.enabled, + config: { ...tool.config }, + }; + } + return plugins; +} + +function configField(field: PluginConfigField) { + const props = { + label: field.label || field.name, + name: field.name, + rules: field.required + ? [{ required: true, message: field.label || field.name }] + : undefined, + extra: field.help, + }; + if (field.type === "password") { + return ( + + + + ); + } + if (field.type === "number") { + return ( + + + + ); + } + return ( + + + + ); +} + +export default function AgentPluginsPanel({ agentId }: AgentPluginsPanelProps) { + const { t } = useTranslation(); + const [plugins, setPlugins] = useState([]); + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(null); + const [detailId, setDetailId] = useState(null); + const [configTool, setConfigTool] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + if (!agentId) { + setPlugins([]); + setTools([]); + return; + } + setLoading(true); + try { + const [pluginResult, toolResult] = await Promise.all([ + pluginsApi.listAgentPlugins(agentId), + pluginsApi.listAgentTools(agentId), + ]); + setPlugins(pluginResult.plugins); + setTools(toolResult.tools); + } catch (error) { + message.error(apiErrorMessage(error, t("plugins.loadError"), t)); + } finally { + setLoading(false); + } + }, [agentId, t]); + + useEffect(() => { + // Defer to a microtask so the initial setLoading(true) inside `load` + // doesn't run synchronously within the effect body. + queueMicrotask(() => { + void load(); + }); + }, [load]); + + const detail = plugins.find((plugin) => plugin.id === detailId) ?? null; + const detailTools = useMemo( + () => tools.filter((tool) => tool.plugin_id === detailId), + [detailId, tools], + ); + + const togglePlugin = async (plugin: AgentPlugin, enabled: boolean) => { + if (!agentId || !plugin.global_enabled) return; + setSaving(`plugin:${plugin.id}`); + try { + const result = await pluginsApi.patchAgentPlugins(agentId, { + [plugin.id]: { enabled }, + }); + setPlugins(result.plugins); + message.success(t("plugins.saved")); + } catch (error) { + message.error(apiErrorMessage(error, t("plugins.saveFailed"), t)); + } finally { + setSaving(null); + } + }; + + const persistTools = async (next: AgentPluginTool[]) => { + if (!agentId) return; + await pluginsApi.patchAgentTools(agentId, toolsConfig(next)); + setTools(next); + }; + + const toggleTool = async (tool: AgentPluginTool, enabled: boolean) => { + const key = `tool:${tool.plugin_id}:${tool.name}`; + setSaving(key); + const next = tools.map((item) => + item.plugin_id === tool.plugin_id && item.name === tool.name + ? { ...item, enabled } + : item, + ); + try { + await persistTools(next); + message.success(t("plugins.saved")); + } catch (error) { + message.error(apiErrorMessage(error, t("plugins.saveFailed"), t)); + } finally { + setSaving(null); + } + }; + + const openConfig = (tool: AgentPluginTool) => { + setConfigTool(tool); + form.setFieldsValue(tool.config); + }; + + const saveConfig = async () => { + if (!configTool) return; + const values = await form.validateFields(); + const key = `tool:${configTool.plugin_id}:${configTool.name}`; + setSaving(key); + try { + await persistTools( + tools.map((item) => + item.plugin_id === configTool.plugin_id && + item.name === configTool.name + ? { ...item, config: values } + : item, + ), + ); + setConfigTool(null); + message.success(t("plugins.saved")); + } catch (error) { + message.error(apiErrorMessage(error, t("plugins.saveFailed"), t)); + } finally { + setSaving(null); + } + }; + + if (!agentId) { + return ( + + ); + } + if (loading) { + return ( +
+ +
+ ); + } + if (plugins.length === 0) { + return ; + } + + return ( +
+
+ + {t("plugins.agentPluginHint")} +
+
+ {plugins.map((plugin) => ( +
+
+
+ +
+

+ {plugin.name || plugin.id} +

+
+ {plugin.kind ? {plugin.kind} : null} + {!plugin.global_enabled ? ( + {t("plugins.globallyDisabled")} + ) : null} +
+
+
+

+ {plugin.description || t("plugins.noDescription")} +

+
+
+ + + void togglePlugin(plugin, checked)} + /> +
+
+ ))} +
+ + setDetailId(null)} + width={500} + destroyOnHidden + > + {detail ? ( + <> + {!detail.global_enabled ? ( + + ) : null} +

+ {detail.description || t("plugins.noDescription")} +

+
+ {t("plugins.agentEnabled")} + void togglePlugin(detail, checked)} + /> +
+

{t("plugins.colTools")}

+ {detailTools.length === 0 ? ( + + ) : ( +
+ {detailTools.map((tool) => { + const key = `tool:${tool.plugin_id}:${tool.name}`; + const configurable = (tool.config_fields?.length ?? 0) > 0; + return ( +
+ + + +
+
+ {tool.name} +
+ {tool.description ? ( +
+ {tool.description} +
+ ) : null} +
+
+ {configurable ? ( +
+
+ ); + })} +
+ )} + + ) : null} +
+ + setConfigTool(null)} + width={420} + destroyOnHidden + extra={ + + } + > +
+ {configTool?.config_fields.map(configField)} +
+
+
+ ); +} diff --git a/dashboard/src/pages/Agent/Personalization/index.tsx b/dashboard/src/pages/Agent/Personalization/index.tsx index 7db33599..d9f93890 100644 --- a/dashboard/src/pages/Agent/Personalization/index.tsx +++ b/dashboard/src/pages/Agent/Personalization/index.tsx @@ -5,6 +5,7 @@ import { Bot, Brain, Notebook, + Puzzle, Sparkles, Waypoints, Wrench, @@ -19,6 +20,7 @@ import SkillsTabs from "../Skills/components/SkillsTabs"; import ToolsPanel from "../Tools/ToolsPanel"; import SubagentManager from "../../Experts/components/SubagentManager"; import MBTISelector from "./components/MBTISelector"; +import AgentPluginsPanel from "./components/AgentPluginsPanel"; import MemoryPanel from "../Memory/MemoryPanel"; import ChannelsPanel from "../Channels/ChannelsPanel"; import styles from "./index.module.less"; @@ -27,6 +29,7 @@ export type PersonalizationTab = | "skills" | "subagents" | "tools" + | "plugins" | "mbti" | "memory" | "channels"; @@ -35,6 +38,7 @@ const PERSONALIZATION_TABS = [ "skills", "subagents", "tools", + "plugins", "mbti", "memory", "channels", @@ -44,6 +48,7 @@ const TAB_ICONS = { skills: Sparkles, subagents: Bot, tools: Wrench, + plugins: Puzzle, mbti: Brain, memory: Notebook, channels: Waypoints, @@ -124,6 +129,18 @@ export default function PersonalizationPage() {
)} + {isMounted("plugins") && ( +
+
+ +
+
+ )} + {isMounted("subagents") && (
= { mobile: "#0EA5E9", teams: "#F97316", misc: "#64748B", - plugin: "#A855F7", }; const TOOL_ICONS: Record = { @@ -142,8 +140,7 @@ interface ToolsPanelProps { } /** - * Full tools surface (builtin + plugin), shared by Personalization tab and - * Experts ToolCatalogDrawer — mirrors SkillsTabs. + * Built-in tools surface shared by Personalization and Experts. */ export default function ToolsPanel({ agentId }: ToolsPanelProps) { const { t } = useTranslation(); @@ -161,9 +158,12 @@ export default function ToolsPanel({ agentId }: ToolsPanelProps) { setLoading(true); try { const res = await agentToolsApi.get(agentId); - setTools(res.tools); + const builtinTools = res.tools.filter( + (tool) => tool.source === "builtin", + ); + setTools(builtinTools); const next: Record = {}; - for (const tool of res.tools) { + for (const tool of builtinTools) { next[toolKey(tool)] = tool.enabled; } setEnabledMap(next); diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 0d296912..0404c331 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -8,6 +8,22 @@ flex-direction: column; } +/* Left gutter so the in-thread turn timeline ticks do not cover bubbles. + Prefer parent `.chatMainWithTurnRail` (title + composer + list). This local + class remains as a fallback when MessageList is mounted alone. */ +.messageListWrapperWithRail { + --chat-turn-rail-gutter: 48px; + + .messageListInner { + padding-inline-start: var(--chat-turn-rail-gutter); + padding-inline-end: var(--chat-column-pad-x, 24px); + + @media (max-width: 863px) { + padding-inline-start: var(--chat-column-pad-x, 24px); + } + } +} + .messageList { flex: 1; overflow-y: auto; @@ -182,6 +198,12 @@ } } +.hitlSegment { + display: flex; + flex-direction: column; + gap: 10px; +} + /* Legacy grouped bubbles (kept for compatibility) */ .assistantGroup { display: flex; @@ -646,36 +668,6 @@ margin-top: 4px; } -.hitlCard { - border: 1px solid var(--fn-border); - border-radius: 8px; - padding: 12px; - background: var(--fn-bg-elevated, var(--fn-bg-container)); -} - -.hitlTitle { - font-weight: 600; - margin-bottom: 8px; -} - -.hitlAction { - margin-bottom: 8px; -} - -.hitlResolved { - margin-top: 4px; - font-size: 13px; - font-weight: 500; -} - -.hitlResolvedApproved { - color: var(--fn-color-success, #389e0d); -} - -.hitlResolvedRejected { - color: var(--fn-color-error, #cf1322); -} - /* ---------- Assistant turn process summary (tools + thinking) ---------- */ .processSummaryRow { display: block; diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.module.less b/dashboard/src/pages/Chat/components/AskQuestionCard.module.less index d7e613ca..c094ed3e 100644 --- a/dashboard/src/pages/Chat/components/AskQuestionCard.module.less +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.module.less @@ -22,6 +22,39 @@ color: var(--fn-text-primary); } +.titleActions { + display: flex; + align-items: center; + gap: 8px; +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--fn-radius-sm); + background: transparent; + color: var(--fn-text-tertiary); + cursor: pointer; + transition: + color var(--fn-transition-fast), + background var(--fn-transition-fast); + + &:hover { + color: var(--fn-text-secondary); + background: var(--fn-bg-secondary); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + .progress { padding: 2px 8px; border: 1px solid var(--fn-border-secondary); diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx b/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx index a87bb33c..99f6e51d 100644 --- a/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx @@ -79,6 +79,24 @@ describe("AskQuestionCard", () => { expect(screen.queryByText("chat.ask.other")).not.toBeInTheDocument(); }); + it("closes the card without answering", () => { + const onSubmit = vi.fn(); + const onDismiss = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "chat.ask.dismiss" })); + + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("collapses an answered question set in message history", () => { const { container } = render( , diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.tsx b/dashboard/src/pages/Chat/components/AskQuestionCard.tsx index 19020530..1ec63f6b 100644 --- a/dashboard/src/pages/Chat/components/AskQuestionCard.tsx +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Input } from "antd"; +import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AskQuestion } from "../../../api/types/hitl"; import styles from "./AskQuestionCard.module.less"; @@ -15,6 +16,8 @@ export interface AskQuestionCardProps { questions: AskQuestion[]; status: "pending" | "approved" | "rejected"; onSubmit?: (message: string) => void; + /** Close the card without answering, ending the pause. */ + onDismiss?: () => void; } /** What the user picked for one question. */ @@ -150,6 +153,7 @@ function AskQuestionCard({ questions, status, onSubmit, + onDismiss, }: AskQuestionCardProps) { const { t } = useTranslation(); const interactive = status === "pending" && Boolean(onSubmit); @@ -233,8 +237,21 @@ function AskQuestionCard({
{t("chat.ask.title")} - - {reviewing ? t("chat.ask.reviewTag") : `${current + 1} / ${total}`} + + + {reviewing ? t("chat.ask.reviewTag") : `${current + 1} / ${total}`} + + {onDismiss ? ( + + ) : null}
diff --git a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx index d7014d1f..5422f5a5 100644 --- a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx +++ b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx @@ -165,7 +165,7 @@ export default function AssistantTurnView({ idx === segmentProcess.length - 1 && hitlLayout.trailingMessages.length === 0; return ( -
+
{showProcess ? ( <> = ({ surfaceVisible = true, }) => { const { t } = useTranslation(); + const currentUser = useCurrentUser(); const [browserMounted, setBrowserMounted] = useState( openTabs.some((tab) => tab.kind === "browser"), ); @@ -171,7 +173,7 @@ const ChatDockPanel: React.FC = ({ return handler; }, []); - const sessionId = resolveBrowserProfile(); + const sessionId = resolveBrowserProfile(currentUser?.id); const activeTab = openTabs.find((tab) => tab.id === activeTabId) ?? openTabs[0] ?? null; const terminalVisible = surfaceVisible && activeTab?.kind === "terminal"; diff --git a/dashboard/src/pages/Chat/components/HitlApprovalCard.module.less b/dashboard/src/pages/Chat/components/HitlApprovalCard.module.less new file mode 100644 index 00000000..b044734a --- /dev/null +++ b/dashboard/src/pages/Chat/components/HitlApprovalCard.module.less @@ -0,0 +1,198 @@ +.card { + border: 1px solid + color-mix( + in srgb, + var(--fn-color-warning, #d48806) 42%, + var(--fn-border-primary) + ); + border-radius: var(--fn-radius-lg); + padding: 14px 16px; + background: color-mix( + in srgb, + var(--fn-color-warning, #d48806) 7%, + var(--fn-bg-primary) + ); + box-shadow: var(--fn-shadow-xs); + box-sizing: border-box; + width: 100%; + max-width: var(--chat-column-max, 960px); +} + +.titleRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.iconWrap { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: var(--fn-radius-full); + color: var(--fn-color-warning, #d48806); + background: color-mix( + in srgb, + var(--fn-color-warning, #d48806) 16%, + transparent + ); +} + +.title { + font-weight: 600; + font-size: 14px; + color: var(--fn-text-primary); +} + +.action + .action { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--fn-border-secondary); +} + +.toolName { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-brand, var(--fn-color-brand)); + margin-bottom: 4px; +} + +.summary { + margin: 0 0 10px; + font-size: 14px; + line-height: 1.55; + color: var(--fn-text-primary); +} + +.rows { + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.row { + display: grid; + grid-template-columns: minmax(64px, 72px) minmax(0, 1fr); + gap: 10px; + align-items: start; + font-size: 13px; + line-height: 1.5; +} + +.row dt { + margin: 0; + color: var(--fn-text-tertiary); + font-weight: 400; +} + +.row dd { + margin: 0; + color: var(--fn-text-primary); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.mono { + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 12px; +} + +.actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; + padding-top: 12px; + border-top: 1px solid var(--fn-border-secondary); +} + +.actionButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 68px; + height: 34px; + padding: 0 15px; + border: 1px solid transparent; + border-radius: var(--fn-radius-md); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: + color var(--fn-transition-fast), + background var(--fn-transition-fast), + border-color var(--fn-transition-fast), + box-shadow var(--fn-transition-fast), + transform var(--fn-transition-fast); + + &:active:not(:disabled) { + transform: scale(0.97); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.primaryAction { + color: var(--fn-color-on-brand); + background: var(--fn-color-brand); + border-color: var(--fn-color-brand); + box-shadow: 0 1px 2px var(--fn-color-brand-shadow); + + &:hover:not(:disabled) { + background: var(--fn-color-brand-hover); + border-color: var(--fn-color-brand-hover); + box-shadow: var(--fn-shadow-brand); + } +} + +.dangerAction { + color: var(--fn-color-error, #cf1322); + background: var(--fn-bg-primary); + border-color: var(--fn-color-error-border, #ffa39e); + + &:hover:not(:disabled) { + background: var(--fn-color-error-bg, #fff2f0); + border-color: var(--fn-color-error, #cf1322); + } +} + +.resolved { + margin-top: 8px; + font-size: 13px; + font-weight: 500; +} + +.approved { + color: var(--fn-color-success, #389e0d); +} + +.rejected { + color: var(--fn-color-error, #cf1322); +} + +@media (max-width: 767px) { + .card { + padding: 12px; + } + + .row { + grid-template-columns: 1fr; + gap: 2px; + } +} diff --git a/dashboard/src/pages/Chat/components/HitlApprovalCard.test.tsx b/dashboard/src/pages/Chat/components/HitlApprovalCard.test.tsx new file mode 100644 index 00000000..6d5e74ef --- /dev/null +++ b/dashboard/src/pages/Chat/components/HitlApprovalCard.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import HitlApprovalCard from "./HitlApprovalCard"; + +describe("HitlApprovalCard", () => { + it("does not dump raw JSON arguments", () => { + render( + , + ); + + expect( + screen.getByText("Read the interactive structure of the current page"), + ).toBeInTheDocument(); + expect(screen.queryByText(/"action"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/\{\s*"action"/)).not.toBeInTheDocument(); + expect(document.querySelector("svg")).toBeTruthy(); + }); + + it("approves and rejects through the decision callback", () => { + const onDecision = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(onDecision).toHaveBeenCalledWith([{ type: "approve" }]); + + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + expect(onDecision).toHaveBeenCalledWith([ + { type: "reject", message: "Rejected by user" }, + ]); + }); + + it("does not treat a pending card without a callback as rejected", () => { + render( + , + ); + + expect(screen.queryByText("Rejected")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/HitlApprovalCard.tsx b/dashboard/src/pages/Chat/components/HitlApprovalCard.tsx new file mode 100644 index 00000000..04d87a69 --- /dev/null +++ b/dashboard/src/pages/Chat/components/HitlApprovalCard.tsx @@ -0,0 +1,104 @@ +import { ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { HitlActionRequest } from "../../../api/types/hitl"; +import { useToolDisplayNames } from "../hooks/toolDisplayNames"; +import { + summarizeHitlAction, + type HitlTranslate, +} from "../utils/summarizeHitlAction"; +import styles from "./HitlApprovalCard.module.less"; + +export interface HitlApprovalCardProps { + actions: HitlActionRequest[]; + status: "pending" | "approved" | "rejected"; + onDecision?: (decisions: Array<{ type: string; message?: string }>) => void; +} + +export default function HitlApprovalCard({ + actions, + status, + onDecision, +}: HitlApprovalCardProps) { + const { t } = useTranslation(); + const toolLabelOf = useToolDisplayNames(); + const interactive = status === "pending" && Boolean(onDecision); + + return ( +
+
+ +
+ {t("chat.hitl.title", "Confirm this action")} +
+
+ {actions.map((action, idx) => { + const view = summarizeHitlAction( + action.name, + action.args, + t as HitlTranslate, + toolLabelOf(action.name), + action.description, + ); + return ( +
+
{view.toolLabel}
+ {view.summary && view.summary !== view.toolLabel ? ( +

{view.summary}

+ ) : null} + {view.rows.length > 0 ? ( +
+ {view.rows.map((row, rowIdx) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ ) : null} +
+ ); + })} + {interactive ? ( +
+ + +
+ ) : status !== "pending" ? ( +
+ {status === "approved" + ? t("chat.hitl.approved", "Approved") + : t("chat.hitl.rejectedLabel", "Rejected")} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index 0ce5a00e..76b12ece 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { Image, Button, Tooltip } from "antd"; +import { Image, Tooltip } from "antd"; import { message as antMessage } from "@/utils/antdMessage"; import Markdown from "../../../components/Markdown/LazyMarkdown"; @@ -39,6 +39,7 @@ import { } from "../../../utils/chatStreamError"; import { MessageFileCard } from "./MessageFileCard"; import AskQuestionCard from "./AskQuestionCard"; +import HitlApprovalCard from "./HitlApprovalCard"; import { extractAskQuestions, isAskHitl } from "../../../api/types/hitl"; import styles from "../index.module.less"; import { @@ -616,58 +617,11 @@ function MessageBubble({ compact ? styles.compact : "" }`} > -
-
- {t("chat.hitl.title", "Tool approval required")} -
- {actions.map((action, idx) => ( -
- {action.name} - {action.args && Object.keys(action.args).length > 0 && ( -
-                  {JSON.stringify(action.args, null, 2)}
-                
- )} -
- ))} - {hitlStatus === "pending" && onHitlDecision ? ( -
- - -
- ) : hitlStatus !== "pending" ? ( -
- {hitlStatus === "approved" - ? t("chat.hitl.approved", "Approved") - : t("chat.hitl.rejectedLabel", "Rejected")} -
- ) : null} -
+
); } diff --git a/dashboard/src/pages/Chat/components/MessageList.tsx b/dashboard/src/pages/Chat/components/MessageList.tsx index 750dc493..dd70414b 100644 --- a/dashboard/src/pages/Chat/components/MessageList.tsx +++ b/dashboard/src/pages/Chat/components/MessageList.tsx @@ -18,6 +18,7 @@ import MessageBubble from "./MessageBubble"; import AssistantTurnView from "./AssistantTurnView"; import ScrollToBottomButton from "./ScrollToBottomButton"; import GeneratingIndicator from "./GeneratingIndicator"; +import TurnTimelineRail from "./TurnTimelineRail"; import { isLiveAssistantTurn } from "./liveAssistantTurn"; import { chatGeneratingPhase } from "./generatingGate"; import { useAutoScroll } from "../hooks/useAutoScroll"; @@ -109,6 +110,8 @@ interface MessageListProps { shellCommandDisabled?: boolean; shellCommandDisabledTitle?: string; compactProcess?: boolean; + /** Notify chat chrome when the in-thread turn rail is shown (for gutter align). */ + onTurnRailVisibilityChange?: (visible: boolean) => void; } interface GroupRenderContext { @@ -157,6 +160,8 @@ function renderMessageGroup( if (msg.role === "assistant") { return (
{ ctx.registerBubbleRef(msg.id, el); }} @@ -184,6 +189,8 @@ function renderMessageGroup( } return (
{ ctx.registerBubbleRef(msg.id, el); }} @@ -203,6 +210,8 @@ function renderMessageGroup( return (
{ for (const msg of group.messages) { ctx.registerBubbleRef(msg.id, el); @@ -259,10 +268,12 @@ export default function MessageList(props: MessageListProps) { shellCommandDisabled, shellCommandDisabledTitle, compactProcess, + onTurnRailVisibilityChange, } = props; const { t } = useTranslation(); const virtuosoRef = useRef(null); + const wrapperRef = useRef(null); const containerRef = useRef(null); const scrollerRef = useRef(null); const endRef = useRef(null); @@ -766,7 +777,22 @@ export default function MessageList(props: MessageListProps) { } return ( -
+
+ {useVirtual ? ( * { + flex: 1; + min-height: 0; + } +} + +.splitMobile .inspectorPane { + flex: 0 0 auto; + max-width: none; + border-left: none; + border-top: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + padding-left: 0; + padding-top: 8px; +} + +.status { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 0; + padding: 24px; +} + +.errorText { + margin: 0; + font-size: 13px; + color: var(--fn-text-secondary); + text-align: center; +} + +.retry { + border: none; + background: transparent; + padding: 0; + font: inherit; + font-size: 13px; + color: var(--fn-color-brand, var(--fn-text-brand)); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +@media (max-width: 767px) { + .drawerTitleRow { + gap: 4px; + padding-right: 0; + } + + .drawerTitleActions { + :global(.octop-btn), + :global(.ant-btn) { + min-width: 32px; + padding-inline: 8px; + } + } + + .body { + padding: 10px 12px; + } +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryDrawer.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryDrawer.test.tsx new file mode 100644 index 00000000..8c29f8d2 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryDrawer.test.tsx @@ -0,0 +1,348 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; + +const { session, useTrajectorySession, messageError } = vi.hoisted(() => { + const session = { + events: [] as TrajectoryEvent[], + metrics: null, + loading: false, + error: false, + hasMore: false, + retry: vi.fn(), + loadEarlier: vi.fn(async () => {}), + refresh: vi.fn(), + }; + return { + session, + useTrajectorySession: vi.fn(() => session), + messageError: vi.fn(), + }; +}); + +const exportMock = vi.fn(); + +vi.mock("../hooks/useTrajectorySession", () => ({ + useTrajectorySession: (...args: unknown[]) => useTrajectorySession(...args), +})); + +vi.mock("../../../hooks/useIsMobile", () => ({ + useIsMobile: () => false, +})); + +vi.mock("@/utils/antdMessage", () => ({ + message: { error: (...args: unknown[]) => messageError(...args) }, +})); + +vi.mock("../../../api/modules/trajectory", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../api/modules/trajectory") + >(); + return { + ...actual, + trajectoryApi: { + ...actual.trajectoryApi, + export: (...args: unknown[]) => exportMock(...args), + }, + }; +}); + +import TrajectoryDrawer from "./TrajectoryDrawer"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +const sampleEvents: TrajectoryEvent[] = [ + event({ + event_id: "u1", + kind: "user", + seq: 1, + turn_id: "turn-a", + summary: "hello", + }), + event({ + event_id: "a1", + kind: "assistant", + seq: 2, + turn_id: "turn-a", + request_seq: 1, + summary: "thinking", + }), + event({ + event_id: "t1", + kind: "tool", + seq: 3, + turn_id: "turn-a", + summary: "open a.py", + payload: { name: "read_file", args: { path: "a.py" }, result: "ok" }, + }), + event({ + event_id: "t2", + kind: "tool", + seq: 4, + turn_id: "turn-b", + summary: "save a.py", + payload: { name: "write_file", args: { path: "a.py" }, result: "saved" }, + }), +]; + +async function renderDrawer(ui: ReactElement) { + const view = render(ui); + await act(async () => { + await Promise.resolve(); + }); + return view; +} + +describe("TrajectoryDrawer", () => { + beforeEach(() => { + session.events = sampleEvents; + session.loading = false; + session.error = false; + session.hasMore = false; + session.retry.mockReset(); + session.loadEarlier.mockReset(); + session.refresh.mockReset(); + useTrajectorySession.mockClear(); + exportMock.mockReset(); + messageError.mockReset(); + }); + + it("renders the drawer title, Duration toggle, and timeline when open", async () => { + await renderDrawer( + {}} />, + ); + + expect(useTrajectorySession).toHaveBeenCalledWith({ + agentId: "A1", + threadId: "T1", + visible: true, + }); + expect(screen.getByText("Trajectory")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Duration" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Trajectory timeline" }), + ).toBeInTheDocument(); + }); + + it("dims ledger search misses and collapses turns or consecutive calls", async () => { + await renderDrawer( + {}} />, + ); + + expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.getByText("Request #1")).toBeInTheDocument(); + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("write_file")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "read_file" }, + }); + const readRow = screen + .getAllByText("read_file") + .map((node) => node.closest("tr")) + .find((node): node is HTMLTableRowElement => node != null); + const writeRow = screen + .getAllByText("write_file") + .map((node) => node.closest("tr")) + .find((node): node is HTMLTableRowElement => node != null); + expect(readRow).toHaveAttribute("data-search-match", "true"); + expect(writeRow).toHaveAttribute("data-search-match", "false"); + + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "" } }); + + fireEvent.click(screen.getByRole("button", { name: "Turns" })); + expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.queryByText("Request #1")).not.toBeInTheDocument(); + expect(screen.queryByText("read_file")).not.toBeInTheDocument(); + expect(screen.getByText("write_file")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Turns" })); + fireEvent.click(screen.getByRole("button", { name: "Calls" })); + expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.getByText("Request #1")).toBeInTheDocument(); + expect(document.querySelectorAll('tr[data-kind="tool"]')).toHaveLength(0); + expect( + document.querySelectorAll('tr[data-kind="collapsed-summary"]'), + ).toHaveLength(1); + expect( + screen.getByText(/2 tool calls · read_file, write_file/), + ).toBeInTheDocument(); + }); + + it("dims timeline search hits from the full event list while calls are collapsed", async () => { + await renderDrawer( + {}} />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Calls" })); + expect(document.querySelectorAll('tr[data-kind="tool"]')).toHaveLength(0); + + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "read_file" }, + }); + + expect(document.querySelector('[data-event-ids="t1"]')).toHaveAttribute( + "data-search-match", + "true", + ); + expect(document.querySelector('[data-event-ids="u1"]')).toHaveAttribute( + "data-search-match", + "false", + ); + }); + + it("keeps the inspector visible and shows a placeholder until a record is selected", async () => { + await renderDrawer( + {}} />, + ); + + expect(screen.getByTestId("trajectory-inspector-pane")).toBeInTheDocument(); + expect(screen.getByText("Select a record")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /hello/ })); + expect(screen.queryByText("Select a record")).not.toBeInTheDocument(); + expect(screen.getByText("Kind")).toBeInTheDocument(); + }); + + it("switches timeline duration mode from the toolbar", async () => { + session.events = [ + event({ + event_id: "a", + kind: "assistant", + payload: { llm_duration_ms: 100 }, + }), + event({ + event_id: "t", + kind: "tool", + payload: { name: "read_file", tool_duration_ms: 50 }, + }), + ]; + await renderDrawer( + {}} />, + ); + + expect(screen.getByRole("button", { name: /Model/ })).toHaveAttribute( + "data-end", + "1", + ); + + fireEvent.click(screen.getByRole("button", { name: "Duration" })); + expect(screen.getByRole("button", { name: /Model/ })).toHaveAttribute( + "data-end", + "100", + ); + expect(screen.getByRole("button", { name: /Tools/ })).toHaveAttribute( + "data-end", + "150", + ); + }); + + it("wires loadEarlier to the timeline earlier-history control", async () => { + session.hasMore = true; + await renderDrawer( + {}} />, + ); + + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Load earlier history" }), + ); + }); + expect(session.loadEarlier).toHaveBeenCalled(); + }); + + it("exports from the drawer header only", async () => { + const blob = new Blob(["{}\n"], { type: "application/x-ndjson" }); + exportMock.mockResolvedValue(blob); + const createObjectURL = vi.fn(() => "blob:trajectory-export"); + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = vi.fn(); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + + await renderDrawer( + {}} />, + ); + + expect(screen.getAllByRole("button", { name: "Export" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Export" })); + + await waitFor(() => { + expect(exportMock).toHaveBeenCalledWith("A1", "T1"); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(click).toHaveBeenCalled(); + }); + expect(messageError).not.toHaveBeenCalled(); + }); + + it("toasts when export fails", async () => { + exportMock.mockRejectedValue(new Error("export failed")); + + await renderDrawer( + {}} />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Export" })); + + await waitFor(() => { + expect(messageError).toHaveBeenCalledWith("Failed to export trajectory"); + }); + }); + + it("shows empty, loading, and error+retry inside the body", async () => { + session.events = []; + const view = await renderDrawer( + {}} />, + ); + expect( + screen.getByText("Select a session to view trajectory"), + ).toBeInTheDocument(); + + session.loading = true; + view.rerender( + {}} />, + ); + await act(async () => { + await Promise.resolve(); + }); + expect(document.querySelector(".ant-spin")).not.toBeNull(); + + session.loading = false; + session.error = true; + view.rerender( + {}} />, + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText("Failed to load trajectory")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(session.retry).toHaveBeenCalled(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx b/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx new file mode 100644 index 00000000..f0a5cee5 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx @@ -0,0 +1,329 @@ +import { Button, Drawer, Empty, Space, Spin } from "antd"; +import { Download, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { message } from "@/utils/antdMessage"; +import { trajectoryApi } from "../../../api/modules/trajectory"; +import { useIsMobile } from "../../../hooks/useIsMobile"; +import { useTrajectorySession } from "../hooks/useTrajectorySession"; +import { + collapseCallRows, + collapseTurnRows, + collapsibleAssistantIds, + ensureToolCallParents, + filterRows, + toLedgerRow, +} from "../utils/trajectoryModel"; +import { + deriveSwimlaneSpans, + trajectoryFocusEventIds, + type TrajectoryTimeRange, +} from "../utils/trajectoryTimeline"; +import styles from "./TrajectoryDrawer.module.less"; +import TrajectoryInspector from "./TrajectoryInspector"; +import TrajectoryLedger from "./TrajectoryLedger"; +import TrajectoryMetricsBar from "./TrajectoryMetricsBar"; +import TrajectoryTimeline from "./TrajectoryTimeline"; +import TrajectoryToolbar from "./TrajectoryToolbar"; + +export interface TrajectoryDrawerProps { + agentId: string; + threadId: string | null; + open: boolean; + onClose: () => void; +} + +const EMPTY_IDS = new Set(); + +export default function TrajectoryDrawer({ + agentId, + threadId, + open, + onClose, +}: TrajectoryDrawerProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const { + events, + metrics, + loading, + error, + retry, + hasMore, + loadEarlier, + refresh, + } = useTrajectorySession({ + agentId, + threadId, + visible: open, + }); + const [durationOn, setDurationOn] = useState(false); + const [collapseTurn, setCollapseTurn] = useState(false); + const [collapsedAssistants, setCollapsedAssistants] = + useState>(EMPTY_IDS); + const [query, setQuery] = useState(""); + const [range, setRange] = useState(null); + const [selectedEventId, setSelectedEventId] = useState(null); + + useEffect(() => { + setRange(null); + setSelectedEventId(null); + setQuery(""); + setCollapseTurn(false); + setCollapsedAssistants(EMPTY_IDS); + setDurationOn(false); + }, [agentId, threadId]); + + const mode = durationOn ? "duration" : "sequence"; + const toolCallOnlyLabel = t( + "chat.trajectoryToolCallOnly", + "(tool call only)", + ); + const formatCallSummary = (count: number, names: readonly string[]) => { + const namesJoined = names.join(", "); + if (count === 1) { + return t( + "chat.trajectoryCollapsedToolCallOne", + "{{count}} tool call · {{names}}", + { count, names: namesJoined }, + ); + } + return t( + "chat.trajectoryCollapsedToolCallMany", + "{{count}} tool calls · {{names}}", + { count, names: namesJoined }, + ); + }; + const formatTurnSummary = (steps: number, toolCalls: number) => + t( + "chat.trajectoryCollapsedTurn", + "{{steps}} steps · {{toolCalls}} tool calls", + { steps, toolCalls }, + ); + + const displayEvents = useMemo( + () => ensureToolCallParents(events, toolCallOnlyLabel), + [events, toolCallOnlyLabel], + ); + const assistantIdsWithTools = useMemo( + () => collapsibleAssistantIds(displayEvents), + [displayEvents], + ); + const allCallsCollapsed = + assistantIdsWithTools.length > 0 && + assistantIdsWithTools.every((id) => collapsedAssistants.has(id)); + + const ledgerEvents = useMemo(() => { + let rows = displayEvents; + if (collapseTurn) { + rows = collapseTurnRows(rows, formatTurnSummary); + } + if (collapsedAssistants.size > 0) { + rows = collapseCallRows(rows, formatCallSummary, collapsedAssistants); + } + return rows; + }, [displayEvents, collapseTurn, collapsedAssistants, t]); + + const searchMatchIds = useMemo(() => { + if (!query.trim()) return null; + return new Set( + filterRows(displayEvents.map(toLedgerRow), query).map((row) => row.id), + ); + }, [displayEvents, query]); + const focusEventIds = useMemo(() => { + if (range == null) return null; + return trajectoryFocusEventIds( + deriveSwimlaneSpans(displayEvents, mode), + range, + ); + }, [displayEvents, mode, range]); + const selectedEvent = + displayEvents.find((event) => event.event_id === selectedEventId) ?? null; + + const toggleAllCalls = () => { + setCollapsedAssistants(() => { + if (allCallsCollapsed) return EMPTY_IDS; + return new Set(assistantIdsWithTools); + }); + }; + + const onExpandCollapsed = ( + parentEventId: string, + kind: "assistant" | "turn", + ) => { + if (kind === "turn") { + setCollapseTurn(false); + return; + } + setCollapsedAssistants((current) => { + if (!current.has(parentEventId)) return current; + const next = new Set(current); + next.delete(parentEventId); + return next.size === 0 ? EMPTY_IDS : next; + }); + }; + + const onExport = () => { + if (!threadId) return; + void (async () => { + try { + const blob = await trajectoryApi.export(agentId, threadId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `trajectory-${threadId}.jsonl`; + link.click(); + URL.revokeObjectURL(url); + } catch { + message.error( + t("chat.trajectoryExportFailed", "Failed to export trajectory"), + ); + } + })(); + }; + + const drawerTitle = ( +
+ + {t("chat.trajectoryTitle", "Trajectory")} + + + + + +
+ ); + + let body: ReactNode; + if (!threadId) { + body = ( +
+ +
+ ); + } else if (loading && events.length === 0) { + body = ( +
+ +
+ ); + } else if (error && events.length === 0) { + body = ( +
+

+ {t("chat.trajectoryLoadError", "Failed to load trajectory")} +

+ +
+ ); + } else if (events.length === 0) { + body = ( +
+ +
+ ); + } else { + body = ( + <> + setCollapseTurn((value) => !value)} + allCallsCollapsed={allCallsCollapsed} + onToggleAllCalls={toggleAllCalls} + searchQuery={query} + onSearchQueryChange={setQuery} + /> + +
+
+ +
+
+ +
+
+ + + ); + } + + return ( + +
{body}
+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.module.less b/dashboard/src/pages/Chat/components/TrajectoryInspector.module.less new file mode 100644 index 00000000..39066113 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.module.less @@ -0,0 +1,292 @@ +.root { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + :global(.ant-tabs), + :global(.octop-tabs) { + flex: 1 1 0% !important; + min-height: 0; + height: 100%; + display: flex !important; + flex-direction: column; + } + + :global(.ant-tabs-nav), + :global(.octop-tabs-nav) { + flex: none; + margin-bottom: 8px !important; + } + + :global(.ant-tabs-content-holder), + :global(.octop-tabs-content-holder) { + flex: 1 1 0% !important; + min-height: 0 !important; + overflow: hidden !important; + } + + :global(.ant-tabs-content), + :global(.octop-tabs-content) { + height: 100% !important; + } + + :global(.ant-tabs-tabpane), + :global(.octop-tabs-tabpane) { + height: 100%; + overflow: hidden; + } + + :global(.ant-tabs-tabpane-active), + :global(.octop-tabs-tabpane-active) { + display: flex !important; + flex-direction: column; + height: 100% !important; + } +} + +.header { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 0 0 8px; + border-bottom: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); + margin-bottom: 4px; +} + +.headerTitle { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.headerKind { + flex: none; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.03em; + color: var(--fn-text-primary); + + &[data-kind="tool"] { + color: var(--fn-color-warning, #d48806); + } + + &[data-kind="assistant"], + &[data-kind="compacted"] { + color: color-mix(in srgb, #a855f7 55%, #e11d48); + } + + &[data-kind="user"] { + color: color-mix( + in srgb, + var(--fn-color-success, #389e0d) 72%, + var(--fn-text-secondary, #595959) + ); + } + + &[data-kind="context"] { + color: color-mix(in srgb, #0891b2 78%, var(--fn-text-secondary, #595959)); + } +} + +.headerLocation { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 11px; + color: var(--fn-text-tertiary, var(--fn-text-secondary)); +} + +.tabs { + flex: 1; + min-height: 0; +} + +.placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 16px 12px; + font-size: 13px; + color: var(--fn-text-secondary); + text-align: center; +} + +.summary { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 4px 0 0; +} + +.summaryScroll { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-right: 2px; +} + +.section { + margin: 0; + border-top: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); + padding-top: 8px; +} + +.sectionSummary { + cursor: pointer; + list-style: none; + font-size: 12px; + font-weight: 600; + color: var(--fn-text-secondary); + user-select: none; + margin-bottom: 8px; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: "▸"; + display: inline-block; + margin-right: 6px; + color: var(--fn-text-tertiary, #8b8b8b); + transition: transform 120ms ease; + } +} + +.section[open] > .sectionSummary::before { + transform: rotate(90deg); +} + +.sourceJump { + display: inline-flex; + align-items: center; + gap: 2px; + margin: 0; + padding: 0; + border: none; + background: transparent; + font: inherit; + font-size: 13px; + color: var(--fn-color-brand, var(--fn-text-brand)); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand, #1677ff); + outline-offset: 2px; + } +} + +.sourceChevron { + font-size: 14px; + line-height: 1; + color: inherit; +} + +.field { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; + align-items: baseline; + margin: 0; + + dt { + margin: 0; + font-size: 12px; + color: var(--fn-text-tertiary, var(--fn-text-secondary)); + } + + dd { + margin: 0; + font-size: 13px; + color: var(--fn-text-primary); + overflow-wrap: anywhere; + } +} + +.preview { + flex: 1; + min-height: 0; + margin: 0; + padding: 10px 12px; + box-sizing: border-box; + overflow: auto; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 6px; + background: var(--fn-bg-elevated, #fff); + font-size: 13px; + line-height: 1.55; + color: var(--fn-text-primary); + word-break: break-word; +} + +.previewPlain { + flex: 1; + min-height: 0; + margin: 0; + padding: 10px 12px; + box-sizing: border-box; + overflow: auto; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 6px; + background: var(--fn-bg-elevated, #fff); + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.raw { + flex: 1; + min-height: 0; + margin: 0; + padding: 8px 10px; + box-sizing: border-box; + overflow: auto; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 6px; + background: var(--fn-bg-secondary, #f5f5f7); + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-secondary); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx new file mode 100644 index 00000000..f35af7d4 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx @@ -0,0 +1,306 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; + +const eventMock = vi.fn(); + +vi.mock("../../../api/modules/trajectory", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../api/modules/trajectory") + >(); + return { + ...actual, + trajectoryApi: { + ...actual.trajectoryApi, + event: (...args: unknown[]) => eventMock(...args), + }, + }; +}); + +import TrajectoryInspector, { findSourceEventId } from "./TrajectoryInspector"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1_700_000_000, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +describe("findSourceEventId", () => { + it("prefers the assistant event for a request_seq", () => { + const events = [ + event({ event_id: "a", kind: "assistant", request_seq: 3 }), + event({ event_id: "t", kind: "tool", request_seq: 3 }), + ]; + expect(findSourceEventId(events, 3, "t")).toBe("a"); + }); +}); + +describe("TrajectoryInspector", () => { + beforeEach(() => { + eventMock.mockReset(); + }); + + it("shows a placeholder when no event is selected", () => { + render(); + expect(screen.getByText("Select a record")).toBeInTheDocument(); + }); + + it("shows Summary fields and Request Timing for a selected event", () => { + render( + , + ); + + expect(screen.getByRole("tab", { name: "Summary" })).toBeInTheDocument(); + expect(screen.getAllByText("ASSISTANT").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/Request #3/).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Request Timing")).toBeInTheDocument(); + expect(screen.getAllByText("120ms").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("40ms").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("80ms")).toBeInTheDocument(); + expect(screen.getByText("12.5 tok/s")).toBeInTheDocument(); + }); + + it("jumps Source Request # to the parent assistant", () => { + const onSelectEvent = vi.fn(); + const assistant = event({ + event_id: "asst-1", + kind: "assistant", + request_seq: 5, + summary: "calling tools", + }); + const tool = event({ + event_id: "tool-1", + kind: "tool", + request_seq: 5, + summary: "read", + payload: { name: "read" }, + }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("trajectory-source-jump")); + expect(onSelectEvent).toHaveBeenCalledWith("asst-1"); + }); + + it("loads event detail on the Raw tab", async () => { + eventMock.mockResolvedValue( + event({ + event_id: "user-1", + kind: "user", + summary: "hello there", + payload: { content: "hello there" }, + }), + ); + + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Raw" })); + + await waitFor(() => { + expect(eventMock).toHaveBeenCalledWith("A1", "T1", "user-1"); + expect(screen.getByTestId("trajectory-raw")).toHaveTextContent( + '"content": "hello there"', + ); + }); + }); + + it("loads full content on the Preview tab", async () => { + eventMock.mockResolvedValue( + event({ + event_id: "ctx-1", + kind: "context", + summary: "clipped…", + payload: { + label: "AGENTS.md", + content: + "---\nsummary: meta\n---\n\n## Rules\n\nPrefer **BackendWorkspace** paths.", + }, + }), + ); + + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Preview" })); + + await waitFor(() => { + expect(eventMock).toHaveBeenCalledWith("A1", "T1", "ctx-1"); + const preview = screen.getByTestId("trajectory-preview"); + expect(preview).toHaveTextContent("Prefer BackendWorkspace paths"); + // Rendered markdown — not the raw ## / ** markers. + expect(preview.querySelector("h2")).toHaveTextContent("Rules"); + expect(preview.querySelector("strong")).toHaveTextContent( + "BackendWorkspace", + ); + expect(preview).not.toHaveTextContent("summary: meta"); + }); + }); + + it("labels the middle tab Result for tool events", () => { + render( + , + ); + + expect(screen.getByRole("tab", { name: "Result" })).toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: "Preview" }), + ).not.toBeInTheDocument(); + }); + + it("renders tool Result as plain text, not markdown", async () => { + eventMock.mockResolvedValue( + event({ + event_id: "tool-1", + kind: "tool", + summary: "read", + payload: { + name: "read", + result: "/tmp/a.rs\n## Heading\n**bold**", + }, + }), + ); + + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Result" })); + + await waitFor(() => { + const preview = screen.getByTestId("trajectory-preview"); + expect(preview).toHaveTextContent("/tmp/a.rs"); + expect(preview).toHaveTextContent("## Heading"); + expect(preview).toHaveTextContent("**bold**"); + expect(preview.querySelector("h2")).toBeNull(); + expect(preview.querySelector("strong")).toBeNull(); + }); + }); + + it("unwraps MCP content blocks on the Result tab and shows Payload", async () => { + eventMock.mockResolvedValue( + event({ + event_id: "tool-mcp", + kind: "tool", + summary: "tool list_projects", + payload: { + name: "list_projects", + args: {}, + result: [ + { + type: "text", + text: '{\n "id": "1",\n "name": "工作"\n}', + }, + ], + }, + }), + ); + + render( + , + ); + + expect(screen.getByRole("tab", { name: "Payload" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Result" })); + + await waitFor(() => { + const preview = screen.getByTestId("trajectory-preview"); + expect(preview).toHaveTextContent('"name": "工作"'); + expect(preview).not.toHaveTextContent("tool list_projects"); + expect(preview).not.toHaveTextContent('"type": "text"'); + }); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx b/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx new file mode 100644 index 00000000..b13a0e0c --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx @@ -0,0 +1,442 @@ +import { Tabs } from "antd"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + trajectoryApi, + type TrajectoryEvent, +} from "../../../api/modules/trajectory"; +import Markdown from "../../../components/Markdown/LazyMarkdown"; +import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import { formatServerDateTime } from "../../../utils/formatMessageTime"; +import { splitMarkdownFrontmatter } from "../../../utils/markdown"; +import { + kindLabelFor, + formatDurationMs, + coerceToolResultText, + coerceToolArgsText, +} from "../utils/trajectoryModel"; +import styles from "./TrajectoryInspector.module.less"; + +export interface TrajectoryInspectorProps { + agentId: string; + threadId: string; + event: TrajectoryEvent | null; + /** All loaded events — used to resolve Source → Request # jumps. */ + events?: TrajectoryEvent[]; + onSelectEvent?: (eventId: string) => void; +} + +function payloadNumber( + payload: Record, + key: string, +): number | null { + const value = payload[key]; + return typeof value === "number" ? value : null; +} + +/** Prefer the assistant row for a request; fall back to any matching event. */ +export function findSourceEventId( + events: readonly TrajectoryEvent[], + requestSeq: number, + currentEventId?: string, +): string | null { + let fallback: string | null = null; + for (const event of events) { + if (event.request_seq !== requestSeq) continue; + if (event.event_id === currentEventId) continue; + if (event.kind === "assistant") return event.event_id; + if (fallback == null) fallback = event.event_id; + } + return fallback; +} + +function formatStartedAt(ts: number, timeZone?: string): string { + // Trajectory timestamps are unix seconds (float); tolerate ms. + if (!Number.isFinite(ts) || ts <= 0) return "—"; + const epochSec = ts > 1e12 ? ts / 1000 : ts; + return formatServerDateTime(epochSec, timeZone); +} + +function TimingSection({ event }: { event: TrajectoryEvent }) { + const { t } = useTranslation(); + const timeZone = useServerTimezone(); + const total = + payloadNumber(event.payload, "llm_duration_ms") ?? + payloadNumber(event.payload, "tool_duration_ms"); + const ttft = payloadNumber(event.payload, "ttft_ms"); + const tokPerS = payloadNumber(event.payload, "tok_per_s"); + const generation = + total != null && ttft != null && total >= ttft ? total - ttft : null; + + const hasTiming = + total != null || ttft != null || tokPerS != null || event.ts > 0; + if (!hasTiming) return null; + + return ( +
+ + {t("chat.trajectoryInspectorTiming", "Request Timing")} + +
+
+
{t("chat.trajectoryInspectorStarted", "Started")}
+
{formatStartedAt(event.ts, timeZone)}
+
+ {total != null ? ( +
+
+ {t("chat.trajectoryInspectorTotalDuration", "Total duration")} +
+
{formatDurationMs(total)}
+
+ ) : null} + {ttft != null ? ( +
+
{t("chat.trajectoryInspectorTtft", "TTFT")}
+
{formatDurationMs(ttft)}
+
+ ) : null} + {generation != null ? ( +
+
{t("chat.trajectoryInspectorGeneration", "Generation")}
+
{formatDurationMs(generation)}
+
+ ) : null} + {tokPerS != null ? ( +
+
{t("chat.trajectoryInspectorThroughput", "Throughput")}
+
{`${ + Number.isInteger(tokPerS) ? tokPerS : tokPerS.toFixed(1) + } tok/s`}
+
+ ) : null} +
+
+ ); +} + +function SummaryPane({ + event, + events, + onSelectEvent, +}: { + event: TrajectoryEvent; + events: TrajectoryEvent[]; + onSelectEvent?: (eventId: string) => void; +}) { + const { t } = useTranslation(); + const inputTokens = payloadNumber(event.payload, "input_tokens"); + const outputTokens = payloadNumber(event.payload, "output_tokens"); + const sourceId = + event.request_seq != null + ? findSourceEventId(events, event.request_seq, event.event_id) + : null; + const canJump = + sourceId != null && + onSelectEvent != null && + // Jumping from a tool (or other) to its assistant; or from assistant to + // another event in the same request is useful. Skip self-only dead ends. + sourceId !== event.event_id; + + return ( +
+
+
+
{t("chat.trajectoryInspectorKind", "Kind")}
+
{kindLabelFor(event.kind)}
+
+ {event.request_seq != null ? ( +
+
{t("chat.trajectoryInspectorSource", "Source")}
+
+ {canJump ? ( + + ) : ( + `Request #${event.request_seq}` + )} +
+
+ ) : null} +
+
{t("chat.trajectoryInspectorStatus", "Status")}
+
+ {event.is_error + ? t("chat.trajectoryInspectorError", "Error") + : t("chat.trajectoryInspectorCompleted", "Completed")} +
+
+ {inputTokens != null || outputTokens != null ? ( +
+
{t("chat.trajectoryInspectorTokens", "Tokens")}
+
+ {[inputTokens, outputTokens] + .filter((value): value is number => value != null) + .map((value) => `${value} tok`) + .join(" · ")} +
+
+ ) : null} +
+ +
+ ); +} + +function RawPane({ + agentId, + threadId, + eventId, +}: { + agentId: string; + threadId: string; + eventId: string; +}) { + const { t } = useTranslation(); + const [text, setText] = useState(""); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setFailed(false); + void trajectoryApi + .event(agentId, threadId, eventId) + .then((detail) => { + if (!cancelled) { + setText(JSON.stringify(detail.payload, null, 2)); + } + }) + .catch(() => { + if (!cancelled) setFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [agentId, eventId, threadId]); + + return ( +
+      {loading
+        ? t("chat.trajectoryDetailLoading", "Loading detail…")
+        : failed
+        ? t("chat.trajectoryDetailError", "Failed to load event detail")
+        : text}
+    
+ ); +} + +function previewTextFromDetail( + detail: TrajectoryEvent, + fallback: string, +): string { + const payload = detail.payload ?? {}; + if (detail.kind === "tool") { + return ( + coerceToolResultText(payload.result) ?? + coerceToolResultText(payload.output) ?? + coerceToolResultText(payload.content) ?? + (detail.summary || fallback) + ); + } + const content = payload.content; + if (typeof content === "string" && content.trim()) return content; + const text = payload.text; + if (typeof text === "string" && text.trim()) return text; + const result = coerceToolResultText(payload.result); + if (result) return result; + return detail.summary || fallback; +} + +function PayloadPane({ event }: { event: TrajectoryEvent }) { + const { t } = useTranslation(); + const args = + event.payload.args ?? event.payload.arguments ?? event.payload.input; + const text = coerceToolArgsText(args); + return ( +
+ {text || t("chat.trajectoryInspectorEmptyPayload", "No payload")} +
+ ); +} + +function PreviewPane({ + agentId, + threadId, + event, +}: { + agentId: string; + threadId: string; + event: TrajectoryEvent; +}) { + const { t } = useTranslation(); + const plain = event.kind === "tool"; + const initial = plain + ? coerceToolResultText(event.payload.result) || + coerceToolResultText(event.payload.output) || + event.summary || + "" + : event.summary || ""; + const [text, setText] = useState(initial); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const fallback = + (plain + ? coerceToolResultText(event.payload.result) || + coerceToolResultText(event.payload.output) + : null) || + event.summary || + ""; + setLoading(true); + setText(fallback); + void trajectoryApi + .event(agentId, threadId, event.event_id) + .then((detail) => { + if (!cancelled) { + setText(previewTextFromDetail(detail, fallback)); + } + }) + .catch(() => { + /* keep summary fallback */ + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ + agentId, + event.event_id, + event.kind, + event.payload.result, + event.payload.output, + event.summary, + plain, + threadId, + ]); + + return ( +
+ {loading && !text ? ( + t("chat.trajectoryDetailLoading", "Loading detail…") + ) : text ? ( + plain ? ( + text + ) : ( + + ) + ) : null} +
+ ); +} + +export default function TrajectoryInspector({ + agentId, + threadId, + event, + events = [], + onSelectEvent, +}: TrajectoryInspectorProps) { + const { t } = useTranslation(); + + if (event == null) { + return ( +
+ {t("chat.trajectoryInspectorPlaceholder", "Select a record")} +
+ ); + } + + const kindLabel = kindLabelFor(event.kind); + const locationParts = [ + event.request_seq != null ? `Request #${event.request_seq}` : null, + `Step ${event.seq}`, + ].filter(Boolean); + + return ( +
+
+
+ + {kindLabel} + + {locationParts.length > 0 ? ( + + {locationParts.join(" · ")} + + ) : null} +
+
+ + ), + }, + ...(event.kind === "tool" + ? [ + { + key: "payload", + label: t("chat.trajectoryInspectorPayload", "Payload"), + children: , + }, + ] + : []), + { + key: "preview", + label: + event.kind === "tool" + ? t("chat.trajectoryInspectorResult", "Result") + : t("chat.trajectoryInspectorPreview", "Preview"), + children: ( + + ), + }, + { + key: "raw", + label: t("chat.trajectoryInspectorRaw", "Raw"), + children: ( + + ), + }, + ]} + /> +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryLedger.module.less b/dashboard/src/pages/Chat/components/TrajectoryLedger.module.less new file mode 100644 index 00000000..1c94a1bb --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryLedger.module.less @@ -0,0 +1,327 @@ +/* Dense table ledger aligned with DeepSeek Harness ui-trajectory. */ + +.tablePane { + flex: 1; + min-height: 0; + min-width: 0; + overflow: auto; + border: none; + border-radius: 0; + background: transparent; +} + +.table { + --trajectory-turn-accent: color-mix( + in srgb, + var(--fn-color-brand, #1677ff) 22%, + var(--fn-bg-elevated, #fff) + ); + width: 100%; + min-width: 0; + border-spacing: 0; + table-layout: fixed; + color: var(--fn-text-primary); + font-size: 12px; + line-height: 18px; +} + +.row { + cursor: pointer; + + &:hover { + background: var(--fn-bg-secondary, #f5f5f7); + } +} + +.rowSelected { + background: color-mix( + in srgb, + var(--fn-color-brand, #1677ff) 10%, + transparent + ); +} + +.rowError { + background: color-mix( + in srgb, + var(--fn-color-error, #cf1322) 8%, + transparent + ); +} + +.row[data-focus-match="false"], +.row[data-search-match="false"] { + opacity: 0.4; +} + +.row:focus-visible { + outline: 2px solid var(--fn-color-brand, #1677ff); + outline-offset: -2px; +} + +.event, +.content { + box-sizing: border-box; + height: 30px; + padding: 0 8px; + border-bottom: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + vertical-align: middle; +} + +.event { + position: relative; + width: 132px; + padding-left: 28px; + padding-right: 4px; + overflow: visible; +} + +.content { + width: auto; + padding-left: 4px; + color: var(--fn-text-primary); +} + +.eventInner { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: 0; + height: 100%; +} + +.turnRail { + position: absolute; + z-index: 1; + top: -1px; + bottom: -1px; + left: 0; + width: 2px; + background: var(--trajectory-turn-accent); + pointer-events: none; +} + +.row[data-turn-start="true"] .turnRail { + top: 0; +} + +.selectionRail { + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: var(--fn-color-brand, #1677ff); + pointer-events: none; +} + +.turnLabel { + position: absolute; + z-index: 3; + top: 0; + left: 0; + max-width: 48px; + padding: 1px 4px; + border-radius: 0 0 2px 0; + background: var(--fn-bg-secondary, #f5f5f7); + color: var(--fn-text-tertiary, #8b8b8b); + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 8px; + line-height: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + pointer-events: none; +} + +.requestLabel { + flex: none; + color: var(--fn-text-tertiary, #8b8b8b); + font-size: 10px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} + +.kindTag { + box-sizing: border-box; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + gap: 0; + height: 19px; + width: 19px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.035em; + line-height: 16px; + user-select: none; + overflow: hidden; +} + +.kindTagLabel { + display: none; +} + +.kindIcon { + display: block; + flex: none; +} + +.kindUser { + color: color-mix( + in srgb, + var(--fn-color-success, #389e0d) 72%, + var(--fn-text-secondary, #595959) + ); + background: color-mix( + in srgb, + var(--fn-color-success, #389e0d) 14%, + transparent + ); +} + +.kindContext { + color: color-mix(in srgb, #0891b2 78%, var(--fn-text-secondary, #595959)); + background: color-mix(in srgb, #0891b2 14%, transparent); +} + +.kindAssistant { + color: color-mix(in srgb, #a855f7 55%, #e11d48); + background: color-mix( + in srgb, + color-mix(in srgb, #a855f7 55%, #e11d48) 15%, + var(--fn-bg-elevated, #fff) + ); +} + +.kindTool { + color: var(--fn-color-warning, #d48806); + background: color-mix( + in srgb, + var(--fn-color-warning, #d48806) 16%, + transparent + ); +} + +.kindSystem { + color: var(--fn-text-secondary, #595959); + background: var(--fn-bg-secondary, #f5f5f7); +} + +.contentText { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolCallOnlyLabel { + color: var(--fn-text-tertiary, #8b8b8b); +} + +.collapsedCalls { + margin-left: 6px; + color: var(--fn-text-secondary, #595959); +} + +.collapsedSummary { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fn-text-tertiary, #8b8b8b); +} + +.collapsedEllipsis { + margin-right: 4px; +} + +.rowCollapsedSummary { + td { + padding-top: 2px; + padding-bottom: 2px; + } +} + +.toolLine { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 12px; + line-height: 18px; +} + +.toolName { + flex: none; + color: var(--fn-text-primary); +} + +.toolArgs { + margin-left: 6px; + color: var(--fn-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arrow { + flex: none; + margin: 0 8px; + color: var(--fn-text-tertiary, #8b8b8b); +} + +.toolResult { + min-width: 0; + color: var(--fn-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (min-width: 1100px) { + .event { + width: 168px; + } + + .kindTag { + width: auto; + max-width: 92px; + padding: 0 5px; + gap: 4px; + justify-content: flex-start; + } + + .kindTagLabel { + display: inline-block; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryLedger.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryLedger.test.tsx new file mode 100644 index 00000000..b0b9b3c4 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryLedger.test.tsx @@ -0,0 +1,295 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import TrajectoryLedger from "./TrajectoryLedger"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +const idle = { + selectedEventId: null as string | null, + onSelect: () => {}, + focusEventIds: null as ReadonlySet | null, + searchMatchIds: null as ReadonlySet | null, +}; + +describe("TrajectoryLedger", () => { + it("renders tool names and assistant Request # labels", () => { + render( + , + ); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Request #3")).toBeInTheDocument(); + expect(screen.getByText("TOOL")).toBeInTheDocument(); + expect(screen.getByText("ASSISTANT")).toBeInTheDocument(); + }); + + it("calls onSelect when a row is clicked and does not expand Raw inline", () => { + const onSelect = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /hello there/ })); + expect(onSelect).toHaveBeenCalledWith("user-1"); + expect(screen.queryByTestId("trajectory-payload")).not.toBeInTheDocument(); + }); + + it("marks the selected row and dims rows outside the focus set", () => { + render( + , + ); + + const kept = screen.getByRole("button", { name: /kept/ }); + const dimmed = screen.getByRole("button", { name: /dimmed/ }); + expect(kept).toHaveAttribute("aria-selected", "true"); + expect(dimmed).toHaveAttribute("aria-selected", "false"); + expect(kept).toHaveAttribute("data-focus-match", "true"); + expect(dimmed).toHaveAttribute("data-focus-match", "false"); + }); + + it("scrolls the selected row into view", () => { + const scrollIntoView = vi.fn(); + const proto = Element.prototype as Element & { + scrollIntoView: typeof scrollIntoView; + }; + const original = proto.scrollIntoView; + proto.scrollIntoView = scrollIntoView; + + try { + const { rerender } = render( + , + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(scrollIntoView).toHaveBeenCalled(); + } finally { + proto.scrollIntoView = original; + } + }); + + it("inserts a turn header when turn_id changes", () => { + render( + , + ); + + const headers = screen.getAllByTestId("trajectory-turn-header"); + expect(headers).toHaveLength(2); + expect(headers[0]).toHaveTextContent("T1"); + expect(headers[1]).toHaveTextContent("T2"); + }); + + it("follows the live tail when near the bottom", () => { + const { rerender } = render( + , + ); + + const pane = screen.getByTestId("trajectory-ledger-pane"); + Object.defineProperty(pane, "clientHeight", { + configurable: true, + value: 100, + }); + Object.defineProperty(pane, "scrollHeight", { + configurable: true, + get() { + return 400; + }, + }); + let scrollTop = 0; + Object.defineProperty(pane, "scrollTop", { + configurable: true, + get() { + return scrollTop; + }, + set(value: number) { + scrollTop = value; + }, + }); + + rerender( + , + ); + + expect(scrollTop).toBe(400); + }); + + it("does not follow the live tail after the user scrolls up", () => { + const { rerender } = render( + , + ); + + const pane = screen.getByTestId("trajectory-ledger-pane"); + Object.defineProperty(pane, "clientHeight", { + configurable: true, + value: 100, + }); + Object.defineProperty(pane, "scrollHeight", { + configurable: true, + value: 500, + }); + let scrollTop = 50; + Object.defineProperty(pane, "scrollTop", { + configurable: true, + get() { + return scrollTop; + }, + set(value: number) { + scrollTop = value; + }, + }); + + fireEvent.scroll(pane); + expect(scrollTop).toBe(50); + + rerender( + , + ); + + expect(scrollTop).toBe(50); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryLedger.tsx b/dashboard/src/pages/Chat/components/TrajectoryLedger.tsx new file mode 100644 index 00000000..fd6ae078 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryLedger.tsx @@ -0,0 +1,357 @@ +import { Fragment, useEffect, useLayoutEffect, useRef } from "react"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import { laneForKind, toLedgerRow } from "../utils/trajectoryModel"; +import styles from "./TrajectoryLedger.module.less"; + +/** px from bottom — stay pinned to the live tail while the user is near it */ +const FOLLOW_TAIL_THRESHOLD_PX = 80; + +export interface TrajectoryLedgerProps { + events: TrajectoryEvent[]; + selectedEventId: string | null; + onSelect: (eventId: string) => void; + /** Expand a folded assistant/turn summary row (DSH click-to-expand). */ + onExpandCollapsed?: ( + parentEventId: string, + kind: "assistant" | "turn", + ) => void; + focusEventIds: ReadonlySet | null; + searchMatchIds: ReadonlySet | null; +} + +function distanceFromBottom(el: HTMLElement): number { + return el.scrollHeight - el.scrollTop - el.clientHeight; +} + +function matchAttr( + ids: ReadonlySet | null, + eventId: string, +): "true" | "false" | undefined { + if (ids == null) return undefined; + return ids.has(eventId) ? "true" : "false"; +} + +function KindIcon({ kind }: { kind: string }) { + if (kind === "tool") { + return ( + + + + + ); + } + if (kind === "assistant" || kind === "compacted") { + return ( + + + + ); + } + return ( + + + + ); +} + +function kindTagClass(kind: string): string { + const lane = laneForKind(kind); + if (lane === "tools") return styles.kindTool; + if (lane === "model") return styles.kindAssistant; + if (kind === "context") return styles.kindContext; + if (kind === "system" || kind === "compacted") return styles.kindSystem; + return styles.kindUser; +} + +function ContentCell({ + kind, + title, + content, + toolArgs, + toolResult, + toolCallOnly, + collapsedSummary, +}: { + kind: string; + title: string; + content: string; + toolArgs: string | null; + toolResult: string | null; + toolCallOnly?: boolean; + collapsedSummary?: boolean; +}) { + if (collapsedSummary) { + return ( + + + … + + {content || title} + + ); + } + if (kind === "tool") { + // List API often keeps only `name` in payload; summary already has + // `name {args} → result` (DSH-style). Prefer structured fields when present. + if (!toolArgs && !toolResult) { + return ( + + {content || title} + + ); + } + return ( + + {title} + {toolArgs ? {toolArgs} : null} + {toolResult ? ( + <> + + → + + {toolResult} + + ) : null} + + ); + } + if (toolCallOnly) { + return ( + + {title} + {content && content !== title ? ( + {content} + ) : null} + + ); + } + return {content || title}; +} + +export default function TrajectoryLedger({ + events, + selectedEventId, + onSelect, + onExpandCollapsed, + focusEventIds, + searchMatchIds, +}: TrajectoryLedgerProps) { + const paneRef = useRef(null); + const rowRefs = useRef(new Map()); + const followTailRef = useRef(true); + const lastEventId = events[events.length - 1]?.event_id ?? null; + const turnNumbers = new Map(); + for (const event of events) { + if (event.turn_id == null || turnNumbers.has(event.turn_id)) continue; + turnNumbers.set(event.turn_id, turnNumbers.size + 1); + } + + useEffect(() => { + const pane = paneRef.current; + if (pane == null) return; + const onScroll = () => { + followTailRef.current = + distanceFromBottom(pane) <= FOLLOW_TAIL_THRESHOLD_PX; + }; + pane.addEventListener("scroll", onScroll, { passive: true }); + return () => pane.removeEventListener("scroll", onScroll); + }, []); + + useLayoutEffect(() => { + if (!followTailRef.current || lastEventId == null) return; + const pane = paneRef.current; + if (pane == null) return; + pane.scrollTop = pane.scrollHeight; + }, [events.length, lastEventId]); + + useEffect(() => { + if (selectedEventId == null) return; + const node = rowRefs.current.get(selectedEventId); + if (typeof node?.scrollIntoView !== "function") return; + node.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [selectedEventId]); + + return ( +
+ + + {events.map((event, index) => { + const row = toLedgerRow(event); + const selected = selectedEventId === row.id; + const prevTurn = events[index - 1]?.turn_id; + const turnStart = + event.turn_id != null && event.turn_id !== prevTurn; + const turnNumber = + event.turn_id != null + ? turnNumbers.get(event.turn_id) + : undefined; + const turnLabel = + turnNumber != null ? `T${turnNumber}` : event.turn_id; + const showRequest = + row.kind === "assistant" && + row.requestSeq != null && + !row.collapsedSummary; + const accessibleName = row.collapsedSummary + ? row.content || row.title + : [ + showRequest ? `Request #${row.requestSeq}` : null, + row.kindLabel, + row.kind === "tool" + ? [row.title, row.toolArgs, row.toolResult, row.content] + .filter(Boolean) + .filter( + (part, partIndex, all) => + all.indexOf(part) === partIndex, + ) + .join(" ") + : row.toolCallOnly + ? [row.title, row.content] + .filter(Boolean) + .filter( + (part, partIndex, all) => + all.indexOf(part) === partIndex, + ) + .join(" ") + : row.content || row.title, + ] + .filter(Boolean) + .join(", "); + + const activate = () => { + if ( + row.collapsedSummary && + row.collapsedParentId && + onExpandCollapsed + ) { + onExpandCollapsed( + row.collapsedParentId, + row.collapsedSummaryKind ?? "assistant", + ); + return; + } + onSelect(row.id); + }; + + return ( + + { + if (node) rowRefs.current.set(row.id, node); + else rowRefs.current.delete(row.id); + }} + className={`${styles.row}${ + selected ? ` ${styles.rowSelected}` : "" + }${row.isError ? ` ${styles.rowError}` : ""}${ + row.collapsedSummary ? ` ${styles.rowCollapsedSummary}` : "" + }`} + data-kind={ + row.collapsedSummary ? "collapsed-summary" : row.kind + } + data-turn-start={ + turnStart && !row.collapsedSummary ? "true" : undefined + } + data-selected={selected ? "true" : "false"} + data-focus-match={matchAttr(focusEventIds, row.id)} + data-search-match={matchAttr(searchMatchIds, row.id)} + tabIndex={0} + role="button" + aria-selected={selected} + aria-label={accessibleName} + onClick={activate} + onKeyDown={(keyboardEvent) => { + if ( + keyboardEvent.key === "Enter" || + keyboardEvent.key === " " + ) { + keyboardEvent.preventDefault(); + activate(); + } + }} + > + + + + + ); + })} + +
+ + {selected && !row.collapsedSummary ? ( + + ) : null} + {turnStart && turnLabel && !row.collapsedSummary ? ( + + {turnLabel} + + ) : null} + {!row.collapsedSummary ? ( + + {showRequest ? ( + + {`Request #${row.requestSeq}`} + + ) : null} + + + + {row.kindLabel} + + + + ) : null} + + +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.module.less b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.module.less new file mode 100644 index 00000000..ea9b1e1f --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.module.less @@ -0,0 +1,42 @@ +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + flex: 0 0 auto; + min-height: 28px; + padding: 6px 0 0; + border-top: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + color: var(--fn-text-tertiary, var(--fn-text-secondary)); + font-size: 11px; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} + +.chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + flex: 1 1 auto; + min-width: 0; +} + +.chipGroup { + display: inline-flex; + align-items: baseline; +} + +.sep { + margin: 0 8px; + color: var(--fn-text-tertiary, #8b8b8b); +} + +.chip { + display: inline-flex; + align-items: baseline; + font-size: 11px; + line-height: 1.2; + color: var(--fn-text-secondary); + white-space: nowrap; +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.test.tsx new file mode 100644 index 00000000..97777f5d --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.test.tsx @@ -0,0 +1,49 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { TrajectoryMetrics } from "../../../api/modules/trajectory"; +import TrajectoryMetricsBar from "./TrajectoryMetricsBar"; + +const metrics: TrajectoryMetrics = { + turns: 2, + steps: 5, + llm_duration_ms: null, + tool_duration_ms: 40, + ttft_avg_ms: null, + tok_per_s: 0, + cache_hit_ratio: null, + input_tokens: 10, + output_tokens: null, + cache_read_tokens: null, +}; + +describe("TrajectoryMetricsBar", () => { + it("hides null metric fields and keeps zeros", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-metric="turns"]')).not.toBeNull(); + expect(container.querySelector('[data-metric="steps"]')).not.toBeNull(); + expect( + container.querySelector('[data-metric="tool_duration_ms"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-metric="tok_per_s"]'), + ).toHaveTextContent("0"); + expect( + container.querySelector('[data-metric="input_tokens"]'), + ).not.toBeNull(); + + expect( + container.querySelector('[data-metric="llm_duration_ms"]'), + ).toBeNull(); + expect(container.querySelector('[data-metric="ttft_avg_ms"]')).toBeNull(); + expect( + container.querySelector('[data-metric="cache_hit_ratio"]'), + ).toBeNull(); + expect(container.querySelector('[data-metric="output_tokens"]')).toBeNull(); + expect( + container.querySelector('[data-metric="cache_read_tokens"]'), + ).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.tsx b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.tsx new file mode 100644 index 00000000..12382a3f --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryMetricsBar.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from "react-i18next"; +import { type TrajectoryMetrics } from "../../../api/modules/trajectory"; +import { formatDurationMs, visibleMetrics } from "../utils/trajectoryModel"; +import styles from "./TrajectoryMetricsBar.module.less"; + +interface TrajectoryMetricsBarProps { + agentId: string; + threadId: string; + metrics: TrajectoryMetrics | null; +} + +const METRIC_LABEL: Record< + keyof TrajectoryMetrics, + { key: string; fallback: string } +> = { + turns: { key: "chat.trajectoryMetricTurns", fallback: "Turns" }, + steps: { key: "chat.trajectoryMetricSteps", fallback: "Steps" }, + llm_duration_ms: { + key: "chat.trajectoryMetricLlmMs", + fallback: "LLM", + }, + tool_duration_ms: { + key: "chat.trajectoryMetricToolMs", + fallback: "Tool call", + }, + ttft_avg_ms: { key: "chat.trajectoryMetricTtft", fallback: "TTFT avg" }, + tok_per_s: { key: "chat.trajectoryMetricTokPerS", fallback: "tok/s" }, + cache_hit_ratio: { + key: "chat.trajectoryMetricCacheHit", + fallback: "Cache hit", + }, + input_tokens: { key: "chat.trajectoryMetricInputTokens", fallback: "Input" }, + output_tokens: { + key: "chat.trajectoryMetricOutputTokens", + fallback: "Output", + }, + cache_read_tokens: { + key: "chat.trajectoryMetricCacheRead", + fallback: "Cache read", + }, +}; + +function formatTokenCount(value: number): string { + if (value >= 1000) { + const kilo = value / 1000; + return `${kilo >= 10 ? Math.round(kilo) : kilo.toFixed(1)}k`; + } + return String(Math.round(value)); +} + +function formatMetric(key: keyof TrajectoryMetrics, value: number): string { + if (key === "cache_hit_ratio") { + return `${Math.round(value * 100)}%`; + } + if (key.endsWith("_ms")) { + return formatDurationMs(value); + } + if ( + key === "input_tokens" || + key === "output_tokens" || + key === "cache_read_tokens" + ) { + return formatTokenCount(value); + } + if (key === "tok_per_s") { + return Number.isInteger(value) ? String(value) : value.toFixed(1); + } + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(1); +} + +function chipText( + key: keyof TrajectoryMetrics, + value: number, + label: string, +): string { + if (key === "turns" || key === "steps") { + return `${formatMetric(key, value)} ${label.toLowerCase()}`; + } + if (key === "tok_per_s") { + return `${formatMetric(key, value)} ${label}`; + } + if (key === "cache_hit_ratio") { + return `${label} ${formatMetric(key, value)}`; + } + return `${label} ${formatMetric(key, value)}`; +} + +export default function TrajectoryMetricsBar({ + metrics, +}: TrajectoryMetricsBarProps) { + const { t } = useTranslation(); + const entries = metrics ? visibleMetrics(metrics) : []; + + return ( +
+
+ {entries.map((entry, index) => { + const label = METRIC_LABEL[entry.key]; + const text = chipText( + entry.key, + entry.value, + t(label.key, label.fallback), + ); + return ( + + {index > 0 ? ( + + · + + ) : null} + + {text} + + + ); + })} +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryTimeline.module.less b/dashboard/src/pages/Chat/components/TrajectoryTimeline.module.less new file mode 100644 index 00000000..eeffc8c6 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryTimeline.module.less @@ -0,0 +1,286 @@ +/* Chrome-Network-style three-lane overview (aligned with DSH ui-trajectory). */ + +.root { + position: relative; + z-index: 1; + isolation: isolate; + flex: none; + border-bottom: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + user-select: none; +} + +.plot { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + height: 50px; + overflow: hidden; + background: var(--fn-bg-secondary, #f5f5f7); +} + +.labels { + position: relative; + border-right: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + color: var(--fn-text-tertiary, #8b8b8b); + font-size: 10px; + line-height: 1; +} + +.labels span { + position: absolute; + right: 3px; + display: flex; + align-items: center; + justify-content: flex-end; + height: 8px; + text-align: right; +} + +.labels span:nth-child(1) { + top: 7px; +} + +.labels span:nth-child(2) { + top: 21px; +} + +.labels span:nth-child(3) { + top: 35px; +} + +.track { + position: relative; + overflow: hidden; + cursor: crosshair; + touch-action: none; +} + +.track[data-panning="true"] { + cursor: grabbing; +} + +.track:focus-visible { + outline: 1px solid var(--fn-color-brand, #1677ff); + outline-offset: -1px; +} + +.earlierHistory { + position: absolute; + z-index: 5; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 28px; + align-items: center; + justify-content: flex-start; + appearance: none; + box-sizing: border-box; + padding-left: 3px; + border: 0; + outline: none; + background: linear-gradient( + to right, + var(--fn-bg-secondary, #f5f5f7) 0, + var(--fn-bg-secondary, #f5f5f7) 38%, + transparent 100% + ); + color: var(--fn-text-secondary, #595959); + font-size: 13px; + line-height: 1; + opacity: 0.72; + cursor: pointer; +} + +.earlierHistory:hover { + opacity: 1; +} + +.earlierHistory[aria-disabled="true"], +.earlierHistory:disabled { + cursor: default; +} + +.earlierHistory:focus-visible { + box-shadow: inset 0 0 0 1px var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); +} + +.empty { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--fn-text-tertiary, #8b8b8b); + font-size: 12px; +} + +.lanes { + position: absolute; + z-index: 2; + top: 7px; + bottom: 7px; + left: var(--trajectory-domain-left, 0%); + width: var(--trajectory-domain-width, 100%); +} + +@media (prefers-reduced-motion: no-preference) { + .lanes[data-animate-viewport="true"] { + transition: left 180ms ease-out; + } +} + +.span { + position: absolute; + top: calc(var(--trajectory-span-lane) * 14px); + left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap)); + width: max( + 2px, + calc( + var(--trajectory-span-width) - var(--trajectory-span-gap) - + var(--trajectory-span-gap) + ) + ); + height: 8px; + min-width: 2px; + margin: 0; + padding: 0; + border: none; + border-radius: 1px; + background: var(--fn-text-tertiary, #8b8b8b); + opacity: 0.78; + cursor: pointer; + + &:focus-visible { + outline: 1px solid var(--fn-color-brand, #1677ff); + outline-offset: 1px; + } +} + +.span[data-timeline-span="user"] { + /* DSH Input lane: business/success green, not brand blue */ + background: color-mix( + in srgb, + var(--fn-color-success, #389e0d) 78%, + var(--fn-text-tertiary, #8b8b8b) + ); +} + +.span[data-timeline-span="context"] { + /* Distinct from USER green: cool cyan for injected context */ + background: color-mix(in srgb, #0891b2 82%, var(--fn-text-tertiary, #8b8b8b)); +} + +.span[data-timeline-span="message"], +.span[data-timeline-span="assistant"], +.span[data-timeline-span="compacted"] { + /* DSH model lane: magenta/violet mix rather than primary blue */ + background: color-mix(in srgb, #a855f7 55%, #e11d48); + opacity: 1; +} + +.span[data-timeline-span="tool"] { + background: var(--fn-color-warning, #d48806); + opacity: 1; +} + +.span[data-error="true"] { + background: var(--fn-color-error, #cf1322); +} + +.span[data-selected="false"] { + opacity: 0.2; +} + +.span[data-hovered="true"]:not([data-current="true"]) { + z-index: 1; + opacity: 1; + box-shadow: + 0 0 0 1px var(--fn-bg-secondary, #f5f5f7), + 0 0 0 2px + color-mix(in srgb, var(--fn-color-brand, #1677ff) 80%, transparent); +} + +.span[data-current="true"] { + z-index: 1; + opacity: 1; + box-shadow: + 0 0 0 1px var(--fn-bg-secondary, #f5f5f7), + 0 0 0 2px var(--fn-color-brand, #1677ff); +} + +.span[data-search-match="false"] { + opacity: 0.14; +} + +.selection { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: var(--trajectory-selection-left); + width: var(--trajectory-selection-width); + min-width: 1px; + background: color-mix( + in srgb, + var(--fn-color-brand, #1677ff) 12%, + transparent + ); + box-shadow: + -100vw 0 0 100vw + color-mix(in srgb, var(--fn-bg-primary, #fff) 58%, transparent), + 100vw 0 0 100vw + color-mix(in srgb, var(--fn-bg-primary, #fff) 58%, transparent); + pointer-events: none; +} + +.selectionEdges { + position: absolute; + z-index: 4; + top: 0; + bottom: 0; + left: var(--trajectory-selection-left); + width: var(--trajectory-selection-width); + min-width: 1px; + pointer-events: none; +} + +.hoverLine { + position: absolute; + z-index: 4; + top: 0; + bottom: 0; + left: clamp(0px, calc(var(--trajectory-hover-left) - 1px), calc(100% - 2px)); + width: 2px; + background: var(--fn-color-brand, #1677ff); + pointer-events: none; +} + +.selectionEdges::before, +.selectionEdges::after { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background: var(--fn-color-brand, #1677ff); + content: ""; +} + +.selectionEdges::before { + left: 0; +} + +.selectionEdges::after { + right: 0; +} + +.selectionEdges[data-dragging="true"]::before, +.selectionEdges[data-dragging="true"]::after { + width: 2px; +} + +.selection[data-dragging="true"] { + background: color-mix( + in srgb, + var(--fn-color-brand, #1677ff) 18%, + transparent + ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryTimeline.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryTimeline.test.tsx new file mode 100644 index 00000000..ef8f9aa3 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryTimeline.test.tsx @@ -0,0 +1,254 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import TrajectoryTimeline from "./TrajectoryTimeline"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +const sampleEvents: TrajectoryEvent[] = [ + event({ event_id: "u", kind: "user", seq: 1 }), + event({ + event_id: "a", + kind: "assistant", + seq: 2, + request_seq: 1, + payload: { llm_duration_ms: 100 }, + }), + event({ + event_id: "t1", + kind: "tool", + seq: 3, + payload: { name: "read_file", tool_duration_ms: 50 }, + }), + event({ + event_id: "t2", + kind: "tool", + seq: 4, + payload: { name: "write_file", tool_duration_ms: 50 }, + }), +]; + +const interactiveProps = { + range: null, + onRangeChange: () => {}, + selectedEventId: null, + searchMatchIds: null, + onRecordSelect: () => {}, +} as const; + +function mockTrack(track: HTMLElement): HTMLElement { + vi.spyOn(track, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 400, + bottom: 50, + width: 400, + height: 50, + toJSON: () => ({}), + }); + return track; +} + +function pointer( + target: HTMLElement, + type: "pointerdown" | "pointermove" | "pointerup", + init: { button?: number; pointerId?: number; clientX: number }, +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: init.button ?? 0, + buttons: (init.button ?? 0) === 0 ? 1 : 0, + clientX: init.clientX, + clientY: 10, + }); + Object.defineProperty(event, "pointerId", { + value: init.pointerId ?? 1, + }); + Object.defineProperty(event, "pointerType", { value: "mouse" }); + act(() => { + target.dispatchEvent(event); + }); +} + +describe("TrajectoryTimeline", () => { + it("renders discrete per-event spans on a shared three-lane track", () => { + render( + , + ); + + expect( + screen.getByRole("group", { name: "Trajectory timeline" }), + ).toBeInTheDocument(); + expect(screen.getByText("Input")).toBeInTheDocument(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Tools")).toBeInTheDocument(); + + const spans = screen.getAllByRole("button", { + name: /^(Input|Model|Tools):/, + }); + expect(spans).toHaveLength(4); + expect(spans[0]).toHaveAttribute("data-lane", "input"); + expect(spans[0]).toHaveAttribute("data-timeline-span", "user"); + expect(spans[1]).toHaveAttribute("data-lane", "model"); + expect(spans[1]).toHaveAttribute("data-timeline-span", "message"); + expect(spans[2]).toHaveAttribute("data-lane", "tools"); + expect(spans[2]).toHaveAttribute("data-event-ids", "t1"); + expect(spans[3]).toHaveAttribute("data-event-ids", "t2"); + spans[0].focus(); + expect(spans[0]).toHaveFocus(); + }); + + it("sizes duration-mode spans from payload durations", () => { + render( + , + ); + + const model = screen.getByRole("button", { name: /Model: assistant/ }); + const tools = screen.getAllByRole("button", { name: /Tools: tool/ }); + expect(model).toHaveAttribute("data-start", "0"); + expect(model).toHaveAttribute("data-end", "100"); + expect(tools[0]).toHaveAttribute("data-start", "100"); + expect(tools[0]).toHaveAttribute("data-end", "150"); + expect(tools[1]).toHaveAttribute("data-start", "150"); + expect(tools[1]).toHaveAttribute("data-end", "200"); + }); + + it("calls onRecordSelect when a span is clicked", () => { + const onRecordSelect = vi.fn(); + render( + {}} + selectedEventId={null} + searchMatchIds={null} + onRecordSelect={onRecordSelect} + />, + ); + fireEvent.click(screen.getByRole("button", { name: /Input: user/ })); + expect(onRecordSelect).toHaveBeenCalledWith("u"); + }); + + it("dims non-matching spans when searchMatchIds is set", () => { + render( + {}} + selectedEventId={null} + searchMatchIds={new Set(["u"])} + onRecordSelect={() => {}} + />, + ); + expect( + screen.getByRole("button", { name: /Model: assistant/ }), + ).toHaveAttribute("data-search-match", "false"); + expect(screen.getByRole("button", { name: /Input: user/ })).toHaveAttribute( + "data-search-match", + "true", + ); + }); + + it("commits a range when dragging on the track", () => { + const onRangeChange = vi.fn(); + render( + {}} + />, + ); + const track = mockTrack( + screen.getByRole("group", { name: "Trajectory timeline" }), + ); + pointer(track, "pointerdown", { clientX: 40 }); + pointer(track, "pointermove", { clientX: 200 }); + pointer(track, "pointerup", { clientX: 200 }); + expect(onRangeChange).toHaveBeenCalledTimes(1); + const committed = onRangeChange.mock.calls[0][0] as { + start: number; + end: number; + }; + expect(committed.end).toBeGreaterThan(committed.start); + }); + + it("clears the range on Escape", () => { + const onRangeChange = vi.fn(); + render( + {}} + />, + ); + fireEvent.keyDown( + screen.getByRole("group", { name: "Trajectory timeline" }), + { key: "Escape" }, + ); + expect(onRangeChange).toHaveBeenCalledWith(null); + }); + + it("calls onRecordSelect after a whitespace range commit", async () => { + const onRangeChange = vi.fn(); + const onRecordSelect = vi.fn(); + render( + , + ); + const track = mockTrack( + screen.getByRole("group", { name: "Trajectory timeline" }), + ); + pointer(track, "pointerdown", { clientX: 10 }); + pointer(track, "pointerup", { clientX: 10 }); + expect(onRangeChange).toHaveBeenCalledTimes(1); + expect(onRangeChange.mock.calls[0][0]).not.toBeNull(); + + await Promise.resolve(); + fireEvent.click(screen.getByRole("button", { name: /Input: user/ })); + expect(onRecordSelect).toHaveBeenCalledWith("u"); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryTimeline.tsx b/dashboard/src/pages/Chat/components/TrajectoryTimeline.tsx new file mode 100644 index 00000000..907fc770 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryTimeline.tsx @@ -0,0 +1,697 @@ +import { Tooltip } from "antd"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type PointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import { + clamp, + deriveSwimlaneSpans, + orderedRange, + panDomain, + zoomDomain, + type SwimlaneSpan, + type TrajectoryTimeRange, +} from "../utils/trajectoryTimeline"; +import styles from "./TrajectoryTimeline.module.less"; + +const LANE_INDEX = { input: 0, model: 1, tools: 2 } as const; +const MINIMUM_DRAG_PX = 3; +const MINIMUM_ZOOM_OPERATIONS = 4; +const DURATION_MIN_DOMAIN = 20; +const EDGE_PAN_ZONE_FRACTION = 0.08; +const EDGE_PAN_STEP_FRACTION = 0.025; +const MAXIMUM_EDGE_PAN_PX = 32; +const TIMELINE_TOOLTIP_DELAY_S = 0.5; + +export type TrajectoryTimelineMode = "sequence" | "duration"; + +export interface TrajectoryTimelineProps { + events: TrajectoryEvent[]; + mode: TrajectoryTimelineMode; + range: TrajectoryTimeRange | null; + onRangeChange: (range: TrajectoryTimeRange | null) => void; + selectedEventId: string | null; + searchMatchIds: ReadonlySet | null; + hasEarlier?: boolean; + onLoadEarlier?: () => void | Promise; + onRecordSelect: (eventId: string) => void; + onRecordFocus?: (eventId: string) => void; +} + +interface HoverPoint { + fraction: number; + eventId: string | null; +} + +interface DragGesture { + pointerId: number; + anchorTime: number; + anchorClientX: number; + eventId: string | null; +} + +interface PanGesture { + anchorClientX: number; + anchorStart: number; + moved: boolean; + pannable: boolean; + pointerId: number; +} + +function spanKindAttr(kind: string): string { + if (kind === "assistant") return "message"; + return kind; +} + +function spanPrimaryId(span: SwimlaneSpan): string { + return span.eventIds[0] ?? span.id; +} + +function spanIntersects( + span: SwimlaneSpan, + range: TrajectoryTimeRange, +): boolean { + return span.start <= range.end && span.end >= range.start; +} + +function rangeFraction( + range: TrajectoryTimeRange, + start: number, + duration: number, + minimum: number, + maximum: number, +): { start: number; end: number } { + const bounded = orderedRange( + clamp(range.start, minimum, maximum), + clamp(range.end, minimum, maximum), + ); + return { + start: (bounded.start - start) / duration, + end: (bounded.end - start) / duration, + }; +} + +function centeredRange( + center: number, + width: number, + minimum: number, + maximum: number, +): TrajectoryTimeRange { + const clampedWidth = Math.min(maximum - minimum, Math.max(0, width)); + const start = Math.min( + Math.max(center - clampedWidth / 2, minimum), + maximum - clampedWidth, + ); + return { start, end: start + clampedWidth }; +} + +function eventIdAt(target: EventTarget | null): string | null { + const el = target instanceof HTMLElement ? target : null; + const value = el?.closest("[data-event-ids]")?.getAttribute("data-event-ids"); + if (value == null || value === "") return null; + return value.split(",")[0] ?? null; +} + +function nearestSpan( + spans: readonly SwimlaneSpan[], + timelinePoint: number, +): SwimlaneSpan | undefined { + if (spans.length === 0) return undefined; + const distance = (span: SwimlaneSpan): number => { + if (timelinePoint < span.start) return span.start - timelinePoint; + if (timelinePoint > span.end) return timelinePoint - span.end; + return 0; + }; + return spans.reduce((candidate, span) => + distance(span) < distance(candidate) ? span : candidate, + ); +} + +function EarlierHistoryBoundary({ + loading, + onHover, + onLoad, + label, +}: { + loading: boolean; + onHover: () => void; + onLoad: (() => void) | undefined; + label: string; +}) { + return ( + + ); +} + +export default function TrajectoryTimeline({ + events, + mode, + range, + onRangeChange, + selectedEventId, + searchMatchIds, + hasEarlier = false, + onLoadEarlier, + onRecordSelect, + onRecordFocus, +}: TrajectoryTimelineProps) { + const { t } = useTranslation(); + const spans = useMemo( + () => deriveSwimlaneSpans(events, mode), + [events, mode], + ); + const modelStart = spans.reduce( + (min, span) => Math.min(min, span.start), + spans[0]?.start ?? 0, + ); + const modelEnd = spans.reduce( + (max, span) => Math.max(max, span.end), + modelStart + 1, + ); + const dragRef = useRef(null); + const panRef = useRef(null); + const suppressClickRef = useRef(false); + const rootRef = useRef(null); + const trackRef = useRef(null); + const [draft, setDraft] = useState(null); + const [hover, setHover] = useState(null); + const [loadingEarlier, setLoadingEarlier] = useState(false); + const [panning, setPanning] = useState(false); + const [viewport, setViewport] = useState(null); + const [animateViewport, setAnimateViewport] = useState(false); + + useEffect(() => { + if (range !== null && (range.end < modelStart || range.start > modelEnd)) { + onRangeChange(null); + } + }, [modelEnd, modelStart, onRangeChange, range]); + + useEffect(() => { + setAnimateViewport(false); + setViewport((current) => + current !== null && (current.end < modelStart || current.start > modelEnd) + ? null + : current, + ); + }, [modelEnd, modelStart]); + + useEffect(() => { + if (selectedEventId === null) return; + const selectedSpan = spans.find((span) => + span.eventIds.includes(selectedEventId), + ); + if (selectedSpan === undefined) return; + setAnimateViewport(true); + setViewport((current) => { + if (current === null) return current; + if ( + selectedSpan.end > current.start && + selectedSpan.start < current.end + ) { + return current; + } + const duration = Math.max(1, current.end - current.start); + const desiredStart = + selectedSpan.end <= current.start + ? selectedSpan.start + : selectedSpan.end - duration; + const nextStart = Math.min( + Math.max(desiredStart, modelStart), + Math.max(modelStart, modelEnd - duration), + ); + if (nextStart === current.start) return current; + return { start: nextStart, end: nextStart + duration }; + }); + }, [modelEnd, modelStart, selectedEventId, spans]); + + const fullDuration = Math.max(1, modelEnd - modelStart); + const viewportDuration = Math.min( + fullDuration, + Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)), + ); + const viewportStart = + viewport === null + ? modelStart + : Math.min( + Math.max(viewport.start, modelStart), + modelEnd - viewportDuration, + ); + const domainDuration = viewport === null ? fullDuration : viewportDuration; + const domainStart = viewport === null ? modelStart : viewportStart; + const showsEarlierBoundary = + hasEarlier && (spans.length === 0 || domainStart === modelStart); + const loadEarlier = + onLoadEarlier === undefined || loadingEarlier + ? undefined + : () => { + setLoadingEarlier(true); + void Promise.resolve(onLoadEarlier()).finally(() => { + setLoadingEarlier(false); + }); + }; + + const projectedDomainStyle = { + "--trajectory-domain-left": `${ + (-(domainStart - modelStart) / domainDuration) * 100 + }%`, + "--trajectory-domain-width": `${(fullDuration / domainDuration) * 100}%`, + } as CSSProperties; + const committed = + range === null + ? null + : rangeFraction(range, domainStart, domainDuration, modelStart, modelEnd); + const draftFraction = + draft === null + ? null + : rangeFraction(draft, domainStart, domainDuration, modelStart, modelEnd); + const visibleRange = draftFraction ?? committed; + const activeRange = draft ?? range; + + useEffect(() => { + const root = rootRef.current; + if (root === null) return; + const onWheel = (event: WheelEvent): void => { + event.preventDefault(); + const track = trackRef.current; + if (track === null || spans.length === 0) return; + setAnimateViewport(false); + const rect = track.getBoundingClientRect(); + const anchorFraction = clamp( + (event.clientX - rect.left) / Math.max(1, rect.width), + 0, + 1, + ); + const next = zoomDomain({ + fullStart: modelStart, + fullEnd: modelEnd, + domainStart, + domainEnd: domainStart + domainDuration, + anchorFraction, + zoomFactor: Math.exp(event.deltaY * 0.0015), + minDomain: Math.min( + mode === "sequence" ? MINIMUM_ZOOM_OPERATIONS : DURATION_MIN_DOMAIN, + fullDuration, + ), + }); + if (next.end - next.start >= fullDuration * 0.999) { + setViewport(null); + return; + } + setViewport(next); + }; + root.addEventListener("wheel", onWheel, { passive: false }); + return () => { + root.removeEventListener("wheel", onWheel); + }; + }, [ + domainDuration, + domainStart, + fullDuration, + mode, + modelEnd, + modelStart, + spans.length, + ]); + + const laneName = { + input: t("chat.trajectoryLaneInput", "Input"), + model: t("chat.trajectoryLaneModel", "Model"), + tools: t("chat.trajectoryLaneTools", "Tools"), + }; + const earlierLabel = t("chat.trajectoryLoadEarlier", "Load earlier history"); + + const fractionAt = (event: PointerEvent): number => { + const rect = event.currentTarget.getBoundingClientRect(); + return clamp((event.clientX - rect.left) / Math.max(1, rect.width), 0, 1); + }; + + const capturePointer = (event: PointerEvent) => { + if (typeof event.currentTarget.setPointerCapture === "function") { + event.currentTarget.setPointerCapture(event.pointerId); + } + }; + + const armSuppressTrailingClick = () => { + suppressClickRef.current = true; + queueMicrotask(() => { + suppressClickRef.current = false; + }); + }; + + const onPointerDown = (event: PointerEvent) => { + suppressClickRef.current = false; + if (event.button === 2) { + panRef.current = { + anchorClientX: event.clientX, + anchorStart: domainStart, + moved: false, + pannable: viewport !== null, + pointerId: event.pointerId, + }; + if (viewport !== null) setAnimateViewport(false); + setPanning(true); + capturePointer(event); + return; + } + if (event.button !== 0 || spans.length === 0) return; + const anchor = fractionAt(event); + const anchorTime = domainStart + anchor * domainDuration; + const eventId = eventIdAt(event.target); + setHover({ fraction: anchor, eventId }); + dragRef.current = { + pointerId: event.pointerId, + anchorTime, + anchorClientX: event.clientX, + eventId, + }; + capturePointer(event); + setDraft({ start: anchorTime, end: anchorTime }); + }; + + const onPointerMove = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const fraction = fractionAt(event); + setHover({ fraction, eventId: eventIdAt(event.target) }); + const pan = panRef.current; + if (pan !== null && pan.pointerId === event.pointerId) { + if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) { + pan.moved = true; + } + if (!pan.pannable) return; + setViewport( + panDomain({ + fullStart: modelStart, + fullEnd: modelEnd, + domainStart: pan.anchorStart, + domainEnd: pan.anchorStart + domainDuration, + deltaFraction: + -(event.clientX - pan.anchorClientX) / Math.max(1, rect.width), + }), + ); + return; + } + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + let nextDomainStart = domainStart; + if (viewport !== null) { + const localX = event.clientX - rect.left; + const edgeWidth = Math.min( + MAXIMUM_EDGE_PAN_PX, + Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION), + ); + const direction = + localX < edgeWidth ? -1 : localX > rect.width - edgeWidth ? 1 : 0; + if (direction !== 0) { + const edgeDistance = + direction < 0 + ? edgeWidth - localX + : localX - (rect.width - edgeWidth); + const strength = clamp(edgeDistance / edgeWidth, 0, 1); + const desiredStart = + domainStart + + direction * + domainDuration * + EDGE_PAN_STEP_FRACTION * + Math.max(0.2, strength); + nextDomainStart = Math.min( + Math.max(desiredStart, modelStart), + modelEnd - domainDuration, + ); + if (nextDomainStart !== domainStart) { + setAnimateViewport(false); + setViewport({ + start: nextDomainStart, + end: nextDomainStart + domainDuration, + }); + } + } + } + const pointTime = nextDomainStart + fraction * domainDuration; + setDraft(orderedRange(drag.anchorTime, pointTime)); + }; + + const onPointerEnd = (event: PointerEvent) => { + const pan = panRef.current; + if (pan !== null && pan.pointerId === event.pointerId) { + const moved = + pan.moved || + Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX; + panRef.current = null; + setPanning(false); + if (!moved) onRangeChange(null); + return; + } + const drag = dragRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + const pointFraction = fractionAt(event); + const pointTime = domainStart + pointFraction * domainDuration; + const selected = orderedRange(drag.anchorTime, pointTime); + setHover({ fraction: pointFraction, eventId: eventIdAt(event.target) }); + dragRef.current = null; + setDraft(null); + const click = + Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX; + if (click && drag.eventId !== null) { + return; + } + const minimumSelectionDuration = Math.min( + domainDuration, + fullDuration / Math.max(1, spans.length), + ); + const committedRange = + selected.end - selected.start < minimumSelectionDuration + ? centeredRange( + click ? selected.start : (selected.start + selected.end) / 2, + minimumSelectionDuration, + modelStart, + modelEnd, + ) + : selected; + armSuppressTrailingClick(); + onRangeChange(committedRange); + if (click) { + const nearest = nearestSpan(spans, selected.start); + if (nearest !== undefined) onRecordFocus?.(spanPrimaryId(nearest)); + } + }; + + const onPointerCancel = () => { + dragRef.current = null; + panRef.current = null; + suppressClickRef.current = false; + setDraft(null); + setHover(null); + setPanning(false); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || range === null) return; + event.preventDefault(); + onRangeChange(null); + }; + + const onSpanClick = (eventId: string) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + onRangeChange(null); + onRecordSelect(eventId); + }; + + const empty = spans.length === 0; + const selectionStyle = + visibleRange === null + ? undefined + : ({ + "--trajectory-selection-left": `${visibleRange.start * 100}%`, + "--trajectory-selection-width": `${ + (visibleRange.end - visibleRange.start) * 100 + }%`, + } as CSSProperties); + const hoverStyle = + hover === null + ? undefined + : ({ + "--trajectory-hover-left": `${hover.fraction * 100}%`, + } as CSSProperties); + + return ( +
+
+ +
{ + if (dragRef.current === null && panRef.current === null) { + setHover(null); + } + }} + onDoubleClick={(event) => { + event.preventDefault(); + onRangeChange(null); + }} + onContextMenu={(event) => { + event.preventDefault(); + }} + > + {showsEarlierBoundary ? ( + { + setHover(null); + }} + onLoad={loadEarlier} + label={earlierLabel} + /> + ) : null} + {hover !== null && hover.eventId === null && draft === null ? ( +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryToolbar.module.less b/dashboard/src/pages/Chat/components/TrajectoryToolbar.module.less new file mode 100644 index 00000000..4352e0dc --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryToolbar.module.less @@ -0,0 +1,139 @@ +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.toggles { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + min-width: 0; +} + +.search { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + width: 220px; + max-width: min(220px, 48vw); + height: 26px; + margin-left: auto; + padding: 0 8px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 4px; + background: var(--fn-bg-secondary, #f5f5f7); + cursor: text; + + &:hover { + border-color: var(--fn-border-primary, rgba(0, 0, 0, 0.14)); + } + + &:focus-within { + border-color: var(--fn-border-primary, rgba(0, 0, 0, 0.22)); + background: var(--fn-bg-primary, #fff); + box-shadow: none; + outline: none; + } + + input { + flex: 1 1 auto; + min-width: 0; + height: 100%; + margin: 0; + padding: 0; + border: 0; + outline: none; + background: transparent; + font: inherit; + font-size: 12px; + line-height: 1; + color: var(--fn-text-primary); + appearance: none; + + &::placeholder { + color: var(--fn-text-tertiary, #8b8b8b); + } + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button, + &::-webkit-search-results-button, + &::-webkit-search-results-decoration { + display: none; + } + } +} + +.searchIcon { + flex: none; + color: var(--fn-text-tertiary, #8b8b8b); +} + +.icon { + flex: none; +} + +.toggle, +.switch { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + border: none; + background: transparent; + border-radius: 3px; + padding: 0 7px; + font: inherit; + font-size: 12px; + line-height: 1; + color: var(--fn-text-tertiary, var(--fn-text-secondary)); + cursor: pointer; + + &:hover { + color: var(--fn-text-primary); + background: var(--fn-bg-secondary, #f5f5f7); + } + + &[aria-pressed="true"] { + color: var(--fn-text-primary); + } + + &:focus-visible { + outline: 1px solid var(--fn-color-brand, #1677ff); + outline-offset: 1px; + } +} + +.switchTrack { + position: relative; + display: inline-block; + flex: none; + width: 20px; + height: 10px; + border-radius: 5px; + background: var(--fn-border-secondary, rgba(0, 0, 0, 0.14)); + transition: background-color 120ms ease; + + &[data-on="true"] { + background: var(--fn-color-brand, #1677ff); + } +} + +.switchThumb { + position: absolute; + top: 2px; + left: 2px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--fn-bg-primary, #fff); + transition: transform 120ms ease; + + .switchTrack[data-on="true"] & { + transform: translateX(10px); + } +} diff --git a/dashboard/src/pages/Chat/components/TrajectoryToolbar.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryToolbar.test.tsx new file mode 100644 index 00000000..2028e9fb --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryToolbar.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import TrajectoryToolbar from "./TrajectoryToolbar"; + +describe("TrajectoryToolbar", () => { + it("toggles duration and search", async () => { + const user = userEvent.setup(); + const onDurationOnChange = vi.fn(); + const onSearchQueryChange = vi.fn(); + render( + {}} + allCallsCollapsed={false} + onToggleAllCalls={() => {}} + searchQuery="" + onSearchQueryChange={onSearchQueryChange} + />, + ); + await user.click(screen.getByRole("button", { name: /Duration/i })); + expect(onDurationOnChange).toHaveBeenCalledWith(true); + await user.type(screen.getByRole("searchbox"), "read"); + expect(onSearchQueryChange).toHaveBeenCalled(); + }); + + it("shows fold icons for turns and calls instead of a selected-filter look", () => { + const { rerender } = render( + {}} + allTurnsCollapsed={false} + onToggleAllTurns={() => {}} + allCallsCollapsed={false} + onToggleAllCalls={() => {}} + searchQuery="" + onSearchQueryChange={() => {}} + />, + ); + expect(screen.getByRole("button", { name: /Turns/i })).toHaveAttribute( + "aria-pressed", + "false", + ); + expect(screen.getByRole("button", { name: /Calls/i })).toHaveAttribute( + "aria-pressed", + "false", + ); + + rerender( + {}} + allTurnsCollapsed={true} + onToggleAllTurns={() => {}} + allCallsCollapsed={true} + onToggleAllCalls={() => {}} + searchQuery="" + onSearchQueryChange={() => {}} + />, + ); + expect(screen.getByRole("button", { name: /Duration/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: /Turns/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: /Calls/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TrajectoryToolbar.tsx b/dashboard/src/pages/Chat/components/TrajectoryToolbar.tsx new file mode 100644 index 00000000..92b712aa --- /dev/null +++ b/dashboard/src/pages/Chat/components/TrajectoryToolbar.tsx @@ -0,0 +1,123 @@ +import { Clock, Search, SquareMinus, SquarePlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import styles from "./TrajectoryToolbar.module.less"; + +export interface TrajectoryToolbarProps { + durationOn: boolean; + onDurationOnChange: (next: boolean) => void; + allTurnsCollapsed: boolean; + onToggleAllTurns: () => void; + allCallsCollapsed: boolean; + onToggleAllCalls: () => void; + searchQuery: string; + onSearchQueryChange: (query: string) => void; +} + +export default function TrajectoryToolbar({ + durationOn, + onDurationOnChange, + allTurnsCollapsed, + onToggleAllTurns, + allCallsCollapsed, + onToggleAllCalls, + searchQuery, + onSearchQueryChange, +}: TrajectoryToolbarProps) { + const { t } = useTranslation(); + const searchLabel = t("chat.trajectoryToolbarSearch", "Search trajectory"); + const durationLabel = t("chat.trajectoryToolbarDuration", "Duration"); + const turnsLabel = t("chat.trajectoryToolbarTurns", "Turns"); + const callsLabel = t("chat.trajectoryToolbarCalls", "Calls"); + + return ( +
+
+ + + +
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less b/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less new file mode 100644 index 00000000..e694952c --- /dev/null +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less @@ -0,0 +1,183 @@ +.rail { + pointer-events: none; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + z-index: 10; + width: 48px; + opacity: 0; + visibility: hidden; + transform: translateX(-8px); + transition: + opacity 150ms ease-out, + transform 150ms ease-out, + visibility 150ms ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.railVisible { + pointer-events: none; + opacity: 1; + visibility: visible; + transform: translateX(0); +} + +.railScroll { + pointer-events: auto; + position: absolute; + inset-inline-start: 8px; + top: 50%; + max-height: calc(100% - 6rem); + width: 40px; + transform: translateY(-50%); + overflow-x: hidden; + overflow-y: auto; + padding-block: 4px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.railInner { + position: relative; + width: 40px; +} + +.tickRow { + position: absolute; + inset-inline-start: 0; + top: 0; + height: 14px; + width: 40px; +} + +.tickButton { + display: flex; + align-items: center; + justify-content: flex-start; + width: 40px; + height: 14px; + padding: 0 0 0 2px; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.tickBar { + display: block; + width: 12px; + height: 2px; + border-radius: 999px; + transform-origin: left center; + background: var(--fn-text-quaternary); + transition: + opacity 150ms ease-out, + transform 150ms ease-out, + background-color 150ms ease-out, + height 150ms ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.tickBarFocus { + background: var(--fn-text-primary); +} + +.tickBarMuted { + background: var(--fn-text-quaternary); +} + +.tickBarActive { + background: var(--fn-text-primary); +} + +.previewOverlay { + :global(.ant-popover-inner) { + padding: 10px 12px; + border: 1px solid var(--fn-border-primary); + border-radius: 10px; + background: var(--fn-bg-elevated, var(--fn-bg-primary)); + box-shadow: 0 8px 24px + color-mix(in srgb, var(--fn-text-primary) 12%, transparent); + } + + :global(.ant-popover-arrow) { + display: none; + } +} + +.preview { + display: flex; + flex-direction: column; + gap: 8px; + width: min(20rem, calc(100vw - 2rem)); +} + +.previewUser { + margin: 0; + white-space: pre-line; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-size: 13px; + font-weight: 600; + line-height: 20px; + color: var(--fn-text-primary); +} + +.previewAssistant { + margin: 0; + white-space: pre-line; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + font-size: 13px; + line-height: 20px; + color: color-mix(in srgb, var(--fn-text-primary) 80%, transparent); +} + +.previewAssistantMuted { + color: var(--fn-text-tertiary); +} + +:global(.turn-timeline-flash) { + animation: turnTimelineFlash 700ms ease-out; + border-radius: 12px; +} + +@media (prefers-reduced-motion: reduce) { + :global(.turn-timeline-flash) { + animation: none; + } +} + +@keyframes turnTimelineFlash { + 0% { + box-shadow: 0 0 0 0 + color-mix(in srgb, var(--fn-color-brand) 40%, transparent); + background-color: color-mix( + in srgb, + var(--fn-color-brand) 14%, + transparent + ); + } + 100% { + box-shadow: 0 0 0 0 transparent; + background-color: transparent; + } +} diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx b/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx new file mode 100644 index 00000000..344aeadf --- /dev/null +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx @@ -0,0 +1,260 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatMessage } from "../hooks/useChat"; +import { groupConsecutiveAssistantMessages } from "../utils/messageGrouping"; +import TurnTimelineRail from "./TurnTimelineRail"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === "chat.turnTimeline.jumpToQuery") { + return `Jump ${opts?.index ?? ""}`.trim(); + } + return key; + }, + }), +})); + +function msg( + role: ChatMessage["role"], + id: string, + content: string, +): ChatMessage { + return { + id, + role, + content, + status: "done", + timestamp: Date.now(), + }; +} + +function mountScrollerWithAnchors(ids: string[]) { + const scroller = document.createElement("div"); + Object.defineProperty(scroller, "clientHeight", { + configurable: true, + value: 600, + }); + Object.defineProperty(scroller, "scrollTop", { + configurable: true, + writable: true, + value: 0, + }); + scroller.getBoundingClientRect = () => + ({ + top: 0, + left: 0, + bottom: 600, + right: 400, + width: 400, + height: 600, + x: 0, + y: 0, + toJSON() { + return {}; + }, + }) as DOMRect; + + const bubbleRefsMap = new Map(); + ids.forEach((id, index) => { + const el = document.createElement("div"); + el.dataset.messageId = id; + el.dataset.role = "user"; + el.className = "userBubble messageBubble"; + el.getBoundingClientRect = () => + ({ + top: 40 + index * 200, + left: 80, + bottom: 140 + index * 200, + right: 360, + width: 280, + height: 100, + x: 80, + y: 40 + index * 200, + toJSON() { + return {}; + }, + }) as DOMRect; + scroller.appendChild(el); + bubbleRefsMap.set(id, el); + }); + document.body.appendChild(scroller); + return { scroller, bubbleRefsMap }; +} + +describe("TurnTimelineRail", () => { + beforeEach(() => { + vi.stubGlobal( + "MutationObserver", + class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + callback: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.callback = cb; + } + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { + width: 960, + height: 700, + } as DOMRectReadOnly, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + it("renders ticks for two user turns and jumps on click", async () => { + const messages = [ + msg("user", "u1", "first question"), + msg("assistant", "a1", "first answer"), + msg("user", "u2", "second question"), + msg("assistant", "a2", "second answer"), + ]; + const messageGroups = groupConsecutiveAssistantMessages(messages); + const { scroller, bubbleRefsMap } = mountScrollerWithAnchors(["u1", "u2"]); + const scrollerRef = { current: null as HTMLElement | null }; + const containerRef = { current: scroller as HTMLDivElement | null }; + const virtuosoRef = { current: null }; + const wrapper = document.createElement("div"); + Object.defineProperty(wrapper, "clientWidth", { + configurable: true, + value: 960, + }); + document.body.appendChild(wrapper); + const wrapperRef = { current: wrapper }; + const armProgrammaticGuard = vi.fn(); + const scrollTo = vi.fn(); + scroller.scrollTo = scrollTo; + + render( + , + ); + + const rail = await screen.findByTestId("turn-timeline-rail"); + expect(rail).toHaveAttribute("data-item-count", "2"); + expect(rail).toHaveAttribute("data-visible", "true"); + expect(rail).toHaveAttribute("data-direction", "ltr"); + + fireEvent.click(screen.getByTestId("turn-timeline-tick-0")); + await waitFor(() => { + expect(armProgrammaticGuard).toHaveBeenCalled(); + }); + expect(scrollTo).toHaveBeenCalled(); + }); + + it("marks rtl direction for preview placement", async () => { + document.documentElement.setAttribute("dir", "rtl"); + const messages = [ + msg("user", "u1", "first"), + msg("assistant", "a1", "a"), + msg("user", "u2", "second"), + msg("assistant", "a2", "b"), + ]; + const messageGroups = groupConsecutiveAssistantMessages(messages); + const { scroller, bubbleRefsMap } = mountScrollerWithAnchors(["u1", "u2"]); + const wrapper = document.createElement("div"); + Object.defineProperty(wrapper, "clientWidth", { + configurable: true, + value: 960, + }); + document.body.appendChild(wrapper); + + render( + , + ); + + const rail = await screen.findByTestId("turn-timeline-rail"); + expect(rail).toHaveAttribute("data-direction", "rtl"); + document.documentElement.setAttribute("dir", "ltr"); + }); + + it("hides when fewer than two user turns", () => { + const messages = [ + msg("user", "u1", "only one"), + msg("assistant", "a1", "reply"), + ]; + const messageGroups = groupConsecutiveAssistantMessages(messages); + const { scroller, bubbleRefsMap } = mountScrollerWithAnchors(["u1"]); + const wrapper = document.createElement("div"); + Object.defineProperty(wrapper, "clientWidth", { + configurable: true, + value: 960, + }); + document.body.appendChild(wrapper); + + const { container } = render( + , + ); + + expect( + container.querySelector('[data-testid="turn-timeline-rail"]'), + ).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx b/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx new file mode 100644 index 00000000..5d993b18 --- /dev/null +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx @@ -0,0 +1,426 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from "react"; +import { Popover } from "antd"; +import type { VirtuosoHandle } from "react-virtuoso"; +import { useTranslation } from "react-i18next"; +import type { ChatMessage } from "../hooks/useChat"; +import type { MessageGroup } from "../utils/messageGrouping"; +import { + TURN_TIMELINE_HOVER_CLOSE_DELAY_S, + TURN_TIMELINE_HOVER_OPEN_DELAY_S, + TURN_TIMELINE_MIN_TURNS, + TURN_TIMELINE_MIN_WIDTH_PX, + TURN_TIMELINE_ROW_PX, + TURN_TIMELINE_VIRTUALIZE_THRESHOLD, + alignElementToScrollerTop, + buildTurnTimelineItems, + mergeTurnPositions, + prefersReducedMotion, + readElementDirection, + resolveActiveTurnId, + resolveFlashTarget, + resolveFollowPinnedTurnId, + tickVisualForDistance, + visibleTurnWindow, + type TurnTimelineItem, +} from "../utils/turnTimeline"; +import styles from "./TurnTimelineRail.module.less"; + +const FLASH_CLASS = "turn-timeline-flash"; +const FLASH_MS = 700; +const JUMP_RETRY_FRAMES = 30; + +interface TurnTimelineRailProps { + messages: ChatMessage[]; + messageGroups: MessageGroup[]; + isStreaming?: boolean; + /** When true (pinned near bottom), keep the latest tick active. */ + following?: boolean; + useVirtual: boolean; + firstItemIndex: number; + scrollerRef: RefObject; + containerRef: RefObject; + virtuosoRef: RefObject; + bubbleRefsMap: MutableRefObject>; + wrapperRef: RefObject; + armProgrammaticGuard: (ms?: number) => void; + onVisibilityChange?: (visible: boolean) => void; +} + +function flashElement(anchor: HTMLElement): void { + const el = resolveFlashTarget(anchor); + el.classList.remove(FLASH_CLASS); + void el.offsetWidth; + el.classList.add(FLASH_CLASS); + window.setTimeout(() => { + el.classList.remove(FLASH_CLASS); + }, FLASH_MS); +} + +export default function TurnTimelineRail({ + messages, + messageGroups, + isStreaming, + following = false, + useVirtual, + firstItemIndex, + scrollerRef, + containerRef, + virtuosoRef, + bubbleRefsMap, + wrapperRef, + armProgrammaticGuard, + onVisibilityChange, +}: TurnTimelineRailProps) { + const { t } = useTranslation(); + const railScrollRef = useRef(null); + const [hoverIndex, setHoverIndex] = useState(undefined); + const [activeMessageId, setActiveMessageId] = useState( + undefined, + ); + const [wideEnough, setWideEnough] = useState(false); + const [reducedMotion, setReducedMotion] = useState(false); + const [textDirection, setTextDirection] = useState<"ltr" | "rtl">("ltr"); + const [railViewport, setRailViewport] = useState({ + scrollTop: 0, + height: 0, + }); + + const turns = useMemo( + () => + buildTurnTimelineItems( + messages, + messageGroups, + { + userFallback: t("chat.turnTimeline.userFallback"), + emptyAssistant: t("chat.turnTimeline.emptyAssistant"), + runningAssistant: t("chat.turnTimeline.runningAssistant"), + }, + { isStreaming }, + ), + [messages, messageGroups, isStreaming, t], + ); + + const showRail = wideEnough && turns.length >= TURN_TIMELINE_MIN_TURNS; + + useEffect(() => { + onVisibilityChange?.(showRail); + }, [showRail, onVisibilityChange]); + + useEffect(() => { + setReducedMotion(prefersReducedMotion()); + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onChange = () => setReducedMotion(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const update = () => { + setWideEnough(el.clientWidth >= TURN_TIMELINE_MIN_WIDTH_PX); + setTextDirection(readElementDirection(el)); + }; + update(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + } + const ro = new ResizeObserver(update); + ro.observe(el); + const mo = new MutationObserver(update); + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["dir"], + }); + return () => { + ro.disconnect(); + mo.disconnect(); + }; + }, [wrapperRef]); + + useEffect(() => { + const el = railScrollRef.current; + if (!el || !showRail) return; + const sync = () => { + setRailViewport({ + scrollTop: el.scrollTop, + height: el.clientHeight, + }); + }; + sync(); + el.addEventListener("scroll", sync, { passive: true }); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", sync); + return () => { + el.removeEventListener("scroll", sync); + window.removeEventListener("resize", sync); + }; + } + const ro = new ResizeObserver(sync); + ro.observe(el); + return () => { + el.removeEventListener("scroll", sync); + ro.disconnect(); + }; + }, [showRail, turns.length]); + + const getScroller = useCallback((): HTMLElement | null => { + if (useVirtual) return scrollerRef.current; + return containerRef.current; + }, [useVirtual, scrollerRef, containerRef]); + + const measureActive = useCallback(() => { + const pinned = resolveFollowPinnedTurnId(turns, { following }); + if (pinned) { + setActiveMessageId(pinned); + return; + } + + const scroller = getScroller(); + if (!scroller || turns.length === 0) { + setActiveMessageId(undefined); + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const measured: Array<{ messageId: string; start: number; end: number }> = + []; + for (const turn of turns) { + const el = + bubbleRefsMap.current.get(turn.messageId) ?? + (scroller.querySelector( + `[data-message-id="${CSS.escape(turn.messageId)}"]`, + ) as HTMLElement | null); + if (!el) continue; + const rect = el.getBoundingClientRect(); + const start = scroller.scrollTop + rect.top - scrollerRect.top; + measured.push({ + messageId: turn.messageId, + start, + end: start + rect.height, + }); + } + const positions = mergeTurnPositions({ turns, measured }); + setActiveMessageId( + resolveActiveTurnId({ + positions, + scrollOffsetPx: scroller.scrollTop, + viewportHeightPx: scroller.clientHeight, + }), + ); + }, [bubbleRefsMap, following, getScroller, turns]); + + useEffect(() => { + const scroller = getScroller(); + if (!scroller) return; + const onScroll = () => { + measureActive(); + }; + measureActive(); + scroller.addEventListener("scroll", onScroll, { passive: true }); + return () => scroller.removeEventListener("scroll", onScroll); + }, [getScroller, measureActive, messages.length, useVirtual]); + + const activeIndex = useMemo(() => { + if (!activeMessageId) return -1; + return turns.findIndex((turn) => turn.messageId === activeMessageId); + }, [activeMessageId, turns]); + + useEffect(() => { + if (!showRail || activeIndex < 0 || turns.length < 2) return; + const scroller = railScrollRef.current; + if (!scroller) return; + const rowTop = activeIndex * TURN_TIMELINE_ROW_PX; + const rowBottom = rowTop + TURN_TIMELINE_ROW_PX; + if (rowTop < scroller.scrollTop) { + scroller.scrollTop = rowTop; + } else if (rowBottom > scroller.scrollTop + scroller.clientHeight) { + scroller.scrollTop = rowBottom - scroller.clientHeight; + } + }, [activeIndex, showRail, turns.length]); + + const jumpToTurn = useCallback( + (turn: TurnTimelineItem) => { + const scroller = getScroller(); + if (!scroller) return; + armProgrammaticGuard(reducedMotion ? 200 : 450); + const behavior: ScrollBehavior = reducedMotion ? "auto" : "smooth"; + + const tryAlign = (): boolean => { + const target = + bubbleRefsMap.current.get(turn.messageId) ?? + (scroller.querySelector( + `[data-message-id="${CSS.escape(turn.messageId)}"]`, + ) as HTMLElement | null); + if (!target) return false; + alignElementToScrollerTop(scroller, target, behavior); + flashElement(target); + measureActive(); + return true; + }; + + if (tryAlign()) return; + + if (useVirtual) { + virtuosoRef.current?.scrollToIndex({ + index: firstItemIndex + turn.groupIndex, + align: "start", + behavior: "auto", + }); + let attempts = JUMP_RETRY_FRAMES; + const retry = () => { + if (tryAlign() || --attempts <= 0) return; + window.requestAnimationFrame(retry); + }; + window.requestAnimationFrame(retry); + } + }, + [ + armProgrammaticGuard, + bubbleRefsMap, + firstItemIndex, + getScroller, + measureActive, + reducedMotion, + useVirtual, + virtuosoRef, + ], + ); + + if (turns.length < TURN_TIMELINE_MIN_TURNS) { + return null; + } + + const totalHeight = turns.length * TURN_TIMELINE_ROW_PX; + const forceFull = turns.length < TURN_TIMELINE_VIRTUALIZE_THRESHOLD; + const tickWindow = visibleTurnWindow({ + count: turns.length, + scrollTop: railViewport.scrollTop, + viewportHeight: railViewport.height || totalHeight, + forceFull, + }); + const previewPlacement = textDirection === "rtl" ? "leftTop" : "rightTop"; + + return ( +
+ + ); +} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 8a4c9ec4..32c8c2dc 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -2160,12 +2160,14 @@ export async function resumeHitl( threadId: string, decisions: Array<{ type: string; message?: string }>, onStreamEnd?: () => void, + dismissed = false, ): Promise { const state = getOrCreate(sessionId); state.abortController?.abort(); - const hitlStatus = decisions.some((d) => d.type === "reject") - ? "rejected" - : "approved"; + const hitlStatus = + dismissed || decisions.some((d) => d.type === "reject") + ? "rejected" + : "approved"; resolveHitlPending(state, hitlStatus); beginStream(state, sessionId); notify(state); diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 12f5830f..2156c457 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -1058,15 +1058,23 @@ export function useChat( ( decisions: Array<{ type: string; message?: string }>, storeKey?: string, + dismissed?: boolean, ) => { if (!agentId) return; const key = storeKey || stableSessionId; const threadId = storeKey || (stableSessionId !== "__empty__" ? stableSessionId : ""); if (!threadId || threadId === "__empty__") return; - void chatStore.resumeHitl(key, agentId, threadId, decisions, () => { - void refreshHistory(threadId); - }); + void chatStore.resumeHitl( + key, + agentId, + threadId, + decisions, + () => { + void refreshHistory(threadId); + }, + dismissed, + ); }, [agentId, stableSessionId, refreshHistory], ); diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 7ba748c0..ff286fc7 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -134,9 +134,15 @@ export function useChatComposerResources( .filter((i) => i.status === "active" && i.has_credentials) .map((i) => ({ mcp_server_name: i.mcp_server_name, - label: i.display_name, + label: + currentUserId !== null && i.owner_user_id !== currentUserId + ? `${i.display_name} · ${ + i.owner_display_name || i.owner_username || i.owner_user_id + }` + : i.display_name, kind: i.kind, - default_open: i.default_open === true, + default_open: + i.default_open === true && i.owner_user_id === currentUserId, })); setChatConnectors(options); const allowed = new Set(options.map((o) => o.mcp_server_name)); @@ -165,7 +171,7 @@ export function useChatComposerResources( window.removeEventListener("focus", onFocus); window.removeEventListener(CONNECTORS_CHANGED_EVENT, loadConnectors); }; - }, [resolvedAgentId]); + }, [resolvedAgentId, currentUserId]); useEffect(() => { if (!resolvedAgentId) { diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts index 0fa60191..69e23561 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts @@ -1,7 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { dockFileTabId } from "../utils/dockFilePath"; -import { useChatDockPanel } from "./useChatDockPanel"; +import { ensureNoTrajectoryTab, useChatDockPanel } from "./useChatDockPanel"; describe("useChatDockPanel tabs", () => { it("openFileList focuses the pinned files tab", () => { @@ -61,6 +61,19 @@ describe("useChatDockPanel tabs", () => { expect(result.current.openTabs.map((t) => t.id)).toEqual(["terminal"]); }); + it("ensureNoTrajectoryTab strips leftover trajectory tabs", () => { + expect( + ensureNoTrajectoryTab([ + { id: "files", kind: "files" }, + { id: "trajectory", kind: "trajectory" }, + { id: "browser", kind: "browser" }, + ]), + ).toEqual([ + { id: "files", kind: "files" }, + { id: "browser", kind: "browser" }, + ]); + }); + it("reopening terminal does not add another dock tab", () => { const { result } = renderHook(() => useChatDockPanel(false)); act(() => { @@ -180,6 +193,7 @@ describe("useChatDockPanel tabs", () => { expect(result.current).not.toHaveProperty("openFilePanel"); expect(result.current).not.toHaveProperty("openBrowserPanel"); expect(result.current).not.toHaveProperty("resetDismissOnSessionGone"); + expect(result.current).not.toHaveProperty("toggleTrajectoryPanel"); }); it("closes the dock and clears tabs when agentId changes", () => { diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index d6ae45da..21993ac5 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -32,6 +32,13 @@ export type DockTab = export type DockTabId = DockTab["id"]; +/** Drop retired dock tabs (pre-drawer trajectory) if they appear in stored lists. */ +export function ensureNoTrajectoryTab( + tabs: readonly T[], +): T[] { + return tabs.filter((t) => t.kind !== "trajectory"); +} + function loadPanelMode(): PanelMode { try { const saved = localStorage.getItem(PANEL_MODE_KEY); diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index 69acd868..7ef5dd5f 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -268,10 +268,7 @@ export function useChatSend({ setTimeout(async () => { try { const { browserApi } = await import("../../../api/modules/browser"); - const profile = activeThreadId || "default"; const data = await browserApi.startRecording({ - profile, - agentProfile: profile, name: activeThreadId ? `chat-${activeThreadId}` : "skill-recording", diff --git a/dashboard/src/pages/Chat/hooks/useTrajectorySession.test.ts b/dashboard/src/pages/Chat/hooks/useTrajectorySession.test.ts new file mode 100644 index 00000000..816cc213 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useTrajectorySession.test.ts @@ -0,0 +1,445 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import { useTrajectorySession } from "./useTrajectorySession"; + +const historyMock = vi.fn(); +const metricsMock = vi.fn(); +const streamUrlMock = vi.fn( + (_agentId: string, _threadId: string, afterSeq?: number) => + `http://trajectory.test/stream?after_seq=${afterSeq ?? ""}`, +); + +vi.mock("../../../api/modules/trajectory", () => ({ + trajectoryApi: { + history: (...args: unknown[]) => historyMock(...args), + metrics: (...args: unknown[]) => metricsMock(...args), + streamUrl: (agentId: string, threadId: string, afterSeq?: number): string => + streamUrlMock(agentId, threadId, afterSeq), + }, +})); + +type Listener = (event: MessageEvent) => void; + +class MockEventSource { + static instances: MockEventSource[] = []; + url: string; + close = vi.fn(); + onerror: ((event: Event) => void) | null = null; + private listeners = new Map>(); + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + addEventListener(type: string, listener: EventListener): void { + const set = this.listeners.get(type) ?? new Set(); + set.add(listener as Listener); + this.listeners.set(type, set); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners.get(type)?.delete(listener as Listener); + } + + emit(type: string, data: unknown): void { + const event = { data: JSON.stringify(data) } as MessageEvent; + this.listeners.get(type)?.forEach((listener) => listener(event)); + } +} + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +const toolEvent = event({ + event_id: "tool-1", + kind: "tool", + seq: 1, + payload: { name: "read_file" }, +}); + +const assistantEvent = event({ + event_id: "asst-1", + kind: "assistant", + seq: 2, + request_seq: 1, + summary: "ok", +}); + +describe("useTrajectorySession", () => { + beforeEach(() => { + MockEventSource.instances = []; + historyMock.mockReset(); + metricsMock.mockReset(); + streamUrlMock.mockClear(); + historyMock.mockResolvedValue({ + thread_id: "T1", + events: [toolEvent], + next_before_seq: null, + has_more: false, + }); + metricsMock.mockResolvedValue({ + turns: 1, + steps: 1, + llm_duration_ms: null, + tool_duration_ms: null, + ttft_avg_ms: null, + tok_per_s: null, + cache_hit_ratio: null, + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + }); + vi.stubGlobal("EventSource", MockEventSource); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("loads history and appends live SSE events while visible", async () => { + const { result } = renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(result.current.events).toHaveLength(1)); + expect(historyMock).toHaveBeenCalledWith("A1", "T1"); + expect(MockEventSource.instances).toHaveLength(1); + expect(streamUrlMock).toHaveBeenCalledWith("A1", "T1", 1); + + act(() => { + MockEventSource.instances[0].emit("event", assistantEvent); + }); + + expect(result.current.events.map((row) => row.event_id)).toEqual([ + "tool-1", + "asst-1", + ]); + }); + + it("upserts a live event with the same event_id", async () => { + const { result } = renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(result.current.events).toHaveLength(1)); + + act(() => { + MockEventSource.instances[0].emit("event", { + ...assistantEvent, + event_id: "tool-1", + kind: "assistant", + summary: "updated", + }); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]).toMatchObject({ + event_id: "tool-1", + kind: "assistant", + summary: "updated", + }); + }); + + it("applies live SSE metrics", async () => { + const { result } = renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)); + await waitFor(() => + expect(result.current.metrics).toMatchObject({ turns: 1, steps: 1 }), + ); + + act(() => { + MockEventSource.instances[0].emit("metrics", { + turns: 3, + steps: 8, + llm_duration_ms: 120, + tool_duration_ms: null, + ttft_avg_ms: null, + tok_per_s: null, + cache_hit_ratio: null, + input_tokens: 40, + output_tokens: null, + cache_read_tokens: null, + }); + }); + + expect(result.current.metrics).toMatchObject({ + turns: 3, + steps: 8, + llm_duration_ms: 120, + input_tokens: 40, + }); + }); + + it("does not fetch or subscribe while the panel is hidden", async () => { + renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: false, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(historyMock).not.toHaveBeenCalled(); + expect(MockEventSource.instances).toHaveLength(0); + }); + + it("clears events when the thread changes before the next page loads", async () => { + const { result, rerender } = renderHook( + ({ threadId }: { threadId: string }) => + useTrajectorySession({ + agentId: "A1", + threadId, + visible: true, + }), + { initialProps: { threadId: "T1" } }, + ); + + await waitFor(() => expect(result.current.events).toHaveLength(1)); + + let resolveNext: ((value: unknown) => void) | undefined; + historyMock.mockReturnValue( + new Promise((resolve) => { + resolveNext = resolve; + }), + ); + + rerender({ threadId: "T2" }); + + await waitFor(() => expect(result.current.events).toEqual([])); + expect(resolveNext).toBeTypeOf("function"); + }); + + it("loadEarlier prepends older events and fetches with beforeSeq", async () => { + const olderEvent = event({ + event_id: "tool-0", + kind: "tool", + seq: 0, + }); + + historyMock + .mockResolvedValueOnce({ + thread_id: "T1", + events: [toolEvent], + next_before_seq: 5, + has_more: true, + }) + .mockResolvedValueOnce({ + thread_id: "T1", + events: [olderEvent], + next_before_seq: null, + has_more: false, + }); + + const { result } = renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(result.current.events).toHaveLength(1)); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + await result.current.loadEarlier(); + }); + + expect(historyMock).toHaveBeenLastCalledWith("A1", "T1", { beforeSeq: 5 }); + expect(result.current.events.map((row) => row.event_id)).toEqual([ + "tool-0", + "tool-1", + ]); + expect(result.current.hasMore).toBe(false); + }); + + it("loadEarlier ignores stale responses after thread change", async () => { + const staleOlderEvent = event({ + event_id: "stale-old", + kind: "tool", + seq: 0, + }); + const t2Event = event({ + event_id: "tool-t2", + kind: "tool", + seq: 1, + thread_id: "T2", + }); + + let resolveEarlier: ((value: unknown) => void) | undefined; + + historyMock + .mockResolvedValueOnce({ + thread_id: "T1", + events: [toolEvent], + next_before_seq: 5, + has_more: true, + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveEarlier = resolve; + }), + ) + .mockResolvedValueOnce({ + thread_id: "T2", + events: [t2Event], + next_before_seq: null, + has_more: false, + }); + + const { result, rerender } = renderHook( + ({ threadId }: { threadId: string }) => + useTrajectorySession({ + agentId: "A1", + threadId, + visible: true, + }), + { initialProps: { threadId: "T1" } }, + ); + + await waitFor(() => expect(result.current.events).toHaveLength(1)); + + let loadEarlierPromise: Promise | undefined; + act(() => { + loadEarlierPromise = result.current.loadEarlier(); + }); + + rerender({ threadId: "T2" }); + + await waitFor(() => + expect(result.current.events.map((row) => row.event_id)).toEqual([ + "tool-t2", + ]), + ); + + await act(async () => { + resolveEarlier?.({ + thread_id: "T1", + events: [staleOlderEvent], + next_before_seq: null, + has_more: false, + }); + await loadEarlierPromise; + }); + + expect(result.current.events.map((row) => row.event_id)).toEqual([ + "tool-t2", + ]); + }); + + it("closes the EventSource when the panel is hidden", async () => { + const { rerender } = renderHook( + ({ visible }: { visible: boolean }) => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible, + }), + { initialProps: { visible: true } }, + ); + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)); + + rerender({ visible: false }); + + expect(MockEventSource.instances[0].close).toHaveBeenCalled(); + }); + + it("reconnects EventSource after an error using the last seq", async () => { + renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)); + + act(() => { + MockEventSource.instances[0].onerror?.(new Event("error")); + }); + + await waitFor(() => + expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(2), + ); + expect(streamUrlMock).toHaveBeenLastCalledWith("A1", "T1", 1); + }); + + it("loadEarlier keeps current events when the older page fails", async () => { + historyMock.mockImplementation( + ( + _agentId: string, + _threadId: string, + params?: { beforeSeq?: number }, + ) => { + if (params?.beforeSeq != null) { + return Promise.reject(new Error("network")); + } + return Promise.resolve({ + thread_id: "T1", + events: [toolEvent], + next_before_seq: 5, + has_more: true, + }); + }, + ); + + const { result } = renderHook(() => + useTrajectorySession({ + agentId: "A1", + threadId: "T1", + visible: true, + }), + ); + + await waitFor(() => expect(result.current.hasMore).toBe(true)); + + await act(async () => { + await result.current.loadEarlier(); + }); + + expect(result.current.events.map((row) => row.event_id)).toEqual([ + "tool-1", + ]); + expect(result.current.hasMore).toBe(true); + }); +}); diff --git a/dashboard/src/pages/Chat/hooks/useTrajectorySession.ts b/dashboard/src/pages/Chat/hooks/useTrajectorySession.ts new file mode 100644 index 00000000..5b8371e5 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useTrajectorySession.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + trajectoryApi, + type TrajectoryEvent, + type TrajectoryMetrics, +} from "../../../api/modules/trajectory"; + +interface UseTrajectorySessionOptions { + agentId?: string; + threadId?: string | null; + visible?: boolean; +} + +interface TrajectorySessionState { + events: TrajectoryEvent[]; + metrics: TrajectoryMetrics | null; + loading: boolean; + error: boolean; + hasMore: boolean; + retry: () => void; + loadEarlier: () => Promise; + refresh: () => void; +} + +function isTrajectoryEvent(value: unknown): value is TrajectoryEvent { + if (value == null || typeof value !== "object") return false; + const record = value as Record; + return typeof record.event_id === "string" && typeof record.kind === "string"; +} + +function isTrajectoryMetrics(value: unknown): value is TrajectoryMetrics { + if (value == null || typeof value !== "object") return false; + const record = value as Record; + return typeof record.turns === "number" && typeof record.steps === "number"; +} + +function parseSseData(raw: MessageEvent): unknown { + try { + return JSON.parse(raw.data); + } catch { + return undefined; + } +} + +function upsertByEventId( + prev: TrajectoryEvent[], + incoming: TrajectoryEvent, +): TrajectoryEvent[] { + const index = prev.findIndex((row) => row.event_id === incoming.event_id); + if (index === -1) return [...prev, incoming]; + const next = prev.slice(); + next[index] = incoming; + return next; +} + +export function useTrajectorySession({ + agentId, + threadId, + visible = true, +}: UseTrajectorySessionOptions): TrajectorySessionState { + const [events, setEvents] = useState([]); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [nextBeforeSeq, setNextBeforeSeq] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + const sessionGenRef = useRef(0); + const loadEarlierLockRef = useRef(false); + const lastSeqRef = useRef(undefined); + + const retry = useCallback(() => { + setReloadToken((token) => token + 1); + }, []); + + const refresh = useCallback(() => { + sessionGenRef.current += 1; + setEvents([]); + setMetrics(null); + setHasMore(false); + setNextBeforeSeq(null); + setReloadToken((token) => token + 1); + }, []); + + const loadEarlier = useCallback(async () => { + if (!agentId || !threadId || nextBeforeSeq == null) return; + if (loadEarlierLockRef.current) return; + + loadEarlierLockRef.current = true; + const loadGen = sessionGenRef.current; + try { + const page = await trajectoryApi.history(agentId, threadId, { + beforeSeq: nextBeforeSeq, + }); + if (loadGen !== sessionGenRef.current) return; + + setHasMore(page.has_more); + setNextBeforeSeq(page.next_before_seq); + setEvents((prev) => { + const seen = new Set(prev.map((row) => row.event_id)); + const older = page.events.filter((row) => !seen.has(row.event_id)); + return [...older, ...prev]; + }); + } catch { + /* Keep the already-loaded page; caller can retry. */ + } finally { + loadEarlierLockRef.current = false; + } + }, [agentId, threadId, nextBeforeSeq]); + + useEffect(() => { + sessionGenRef.current += 1; + setEvents([]); + setMetrics(null); + setError(false); + setHasMore(false); + setNextBeforeSeq(null); + lastSeqRef.current = undefined; + }, [agentId, threadId]); + + useEffect(() => { + if (!visible || !agentId || !threadId) return; + + sessionGenRef.current += 1; + const fetchGen = sessionGenRef.current; + let cancelled = false; + let source: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempt = 0; + + const bindSource = (es: EventSource) => { + es.addEventListener("event", (raw) => { + const parsed = parseSseData(raw as MessageEvent); + if (!isTrajectoryEvent(parsed)) return; + lastSeqRef.current = parsed.seq; + setEvents((prev) => upsertByEventId(prev, parsed)); + }); + es.addEventListener("metrics", (raw) => { + const parsed = parseSseData(raw as MessageEvent); + if (!isTrajectoryMetrics(parsed)) return; + setMetrics(parsed); + }); + es.onerror = () => { + es.close(); + if (cancelled || fetchGen !== sessionGenRef.current) return; + const delay = + reconnectAttempt === 0 + ? 0 + : Math.min(1000 * 2 ** reconnectAttempt, 15_000); + reconnectAttempt += 1; + reconnectTimer = setTimeout(() => { + if (cancelled || fetchGen !== sessionGenRef.current) return; + openStream(lastSeqRef.current); + }, delay); + }; + }; + + const openStream = (afterSeq?: number) => { + if (cancelled) return; + source?.close(); + source = new EventSource( + trajectoryApi.streamUrl(agentId, threadId, afterSeq), + ); + bindSource(source); + }; + + setLoading(true); + setError(false); + void trajectoryApi + .history(agentId, threadId) + .then(async (page) => { + if (cancelled || fetchGen !== sessionGenRef.current) return; + setEvents(page.events); + setHasMore(page.has_more); + setNextBeforeSeq(page.next_before_seq); + const lastSeq = page.events[page.events.length - 1]?.seq; + lastSeqRef.current = lastSeq; + try { + const snapshot = await trajectoryApi.metrics(agentId, threadId); + if (!cancelled && fetchGen === sessionGenRef.current) { + setMetrics(snapshot); + } + } catch { + /* Live SSE metrics remain the primary source. */ + } + openStream(lastSeq); + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + if (reconnectTimer != null) clearTimeout(reconnectTimer); + source?.close(); + }; + }, [visible, agentId, threadId, reloadToken]); + + return { + events, + metrics, + loading, + error, + hasMore, + retry, + loadEarlier, + refresh, + }; +} diff --git a/dashboard/src/pages/Chat/index.module.less b/dashboard/src/pages/Chat/index.module.less index 4be09021..e6b0021f 100644 --- a/dashboard/src/pages/Chat/index.module.less +++ b/dashboard/src/pages/Chat/index.module.less @@ -46,6 +46,37 @@ } } +/* Turn timeline rail visible: align title, message column, and composer gutters. */ +.chatMainWithTurnRail { + --chat-turn-rail-gutter: 48px; + + .chatTitleBar { + padding-inline: var(--chat-turn-rail-gutter) + max(var(--chat-column-pad-x, 24px), var(--window-controls-inset-end, 0px)); + padding-block: 0; + } + + .messageListInner { + padding-inline-start: var(--chat-turn-rail-gutter); + padding-inline-end: var(--chat-column-pad-x, 24px); + } + + .chatInput { + padding-inline-start: var(--chat-turn-rail-gutter); + } + + .runUsageBar { + width: 100%; + max-width: var(--chat-column-max, 960px); + padding-inline: var(--chat-turn-rail-gutter) var(--chat-column-pad-x, 24px); + box-sizing: border-box; + } + + @media (max-width: 863px) { + --chat-turn-rail-gutter: var(--chat-column-pad-x, 24px); + } +} + .chatTitleBar { display: flex; align-items: center; diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 95f378ca..0d6a4afe 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -9,9 +9,11 @@ import { FilePen, Terminal, FolderOpen, + Activity, } from "lucide-react"; import { Tooltip } from "antd"; import { message as antMessage } from "@/utils/antdMessage"; +import { showConfirmModal } from "../../utils/confirmModal"; import { useIsMobile } from "../../hooks/useIsMobile"; import { useCurrentUser } from "../../hooks/useCurrentUser"; @@ -37,6 +39,10 @@ import { useChatContextWindow } from "./hooks/useChatContextWindow"; import { useBrowserToolDetection } from "./hooks/useBrowserToolDetection"; import { useSkillRecordingWorkflow } from "./hooks/useSkillRecordingWorkflow"; import { listDockFilePathsForTree } from "./utils/dockFilePath"; +import { + shouldJumpToChromeInstall, + WORKBENCH_BROWSER_PATH, +} from "./utils/chromeInstallGate"; import { isFileToolName } from "./constants"; import { browserApi } from "../../api/modules/browser"; import { octopThreadsApi } from "../../api/modules/octopThreads"; @@ -48,6 +54,7 @@ import WelcomeScreen from "./components/WelcomeScreen"; import AgentNotReadyScreen from "./components/AgentNotReadyScreen"; import AgentProfileDrawer from "../../components/AgentProfileDrawer"; import WorkspaceDrawer from "../Agent/Workspace/components/WorkspaceDrawer"; +import TrajectoryDrawer from "./components/TrajectoryDrawer"; import { useExpertChatWelcome } from "./hooks/useExpertQuickCards"; import { useSkills } from "../Agent/Skills/useSkills"; import { @@ -179,6 +186,8 @@ function ChatPageInner() { [agents, resolvedAgentId], ); const agentChatReady = isAgentChatReady(activeAgent?.state); + const trajectoryEnabled = + activeAgent !== null && activeAgent.config?.enable_trajectory !== false; const sharedExpertViewer = isSharedExpertViewer(activeAgent ?? {}); const noAgents = !agentsLoading && agents.length === 0; @@ -215,7 +224,8 @@ function ChatPageInner() { ); const [agentProfileOpen, setAgentProfileOpen] = useState(false); const [workspaceDrawerOpen, setWorkspaceDrawerOpen] = useState(false); - + const [trajectoryDrawerOpen, setTrajectoryDrawerOpen] = useState(false); + const [turnRailVisible, setTurnRailVisible] = useState(false); const { sessions, loading: sessionsLoading, @@ -255,6 +265,7 @@ function ChatPageInner() { } setAgentProfileOpen(false); setWorkspaceDrawerOpen(false); + setTrajectoryDrawerOpen(false); }, [resolvedAgentId]); // Weak stream resume may skip intermediate tokens — hint once after rebind. @@ -347,6 +358,40 @@ function ChatPageInner() { setActiveTab: setDockActiveTab, } = useChatDockPanel(isMobile, resolvedAgentId); + const chromeCheckInFlightRef = useRef(false); + const handleToggleBrowserPanel = useCallback(async () => { + // A live session means Chrome is already running — skip the probe. + if (browserSessionId) { + toggleBrowserPanel(); + return; + } + if (chromeCheckInFlightRef.current) return; + chromeCheckInFlightRef.current = true; + try { + const env = await browserApi.checkEnvStatus(); + if (shouldJumpToChromeInstall(env)) { + showConfirmModal( + { + title: t("browserWorkspace.chromeMissingTitle"), + content: t("browserWorkspace.chromeMissingJumpToInstall"), + okText: t("common.confirm"), + cancelText: t("common.cancel"), + onOk: () => { + navigate(WORKBENCH_BROWSER_PATH); + }, + }, + { isMobile }, + ); + return; + } + } catch { + // Probe failed — keep the existing open-panel behavior. + } finally { + chromeCheckInFlightRef.current = false; + } + toggleBrowserPanel(); + }, [browserSessionId, isMobile, navigate, t, toggleBrowserPanel]); + const closeToolUiPanel = useCallback( (callId: string) => { closeDockTab(dockTabIdForToolCall(callId)); @@ -641,6 +686,22 @@ function ChatPageInner() { [resumeHitl, activeThreadId], ); + /** Close an ask pause without answering: ``respond`` is the only decision + * the agent allows for ``ask_user_question``, so tell it to wrap up. */ + const handleAskDismiss = useCallback( + (actions: unknown[]) => { + resumeHitl( + actions.map(() => ({ + type: "respond", + message: t("chat.ask.dismissMessage"), + })), + activeThreadId ?? undefined, + true, + ); + }, + [resumeHitl, activeThreadId, t], + ); + useEffect(() => { let cancelled = false; browserApi @@ -828,6 +889,13 @@ function ChatPageInner() { activeThreadId && !hasMessages && (historyLoading || !historyHydrated), ); const showWelcome = !hasMessages && !awaitingThreadHistory; + + useEffect(() => { + if (showWelcome || !agentChatReady || noAgents) { + setTurnRailVisible(false); + } + }, [showWelcome, agentChatReady, noAgents]); + const activeSession = useMemo(() => { if (!activeThreadId || showWelcome) return null; return ( @@ -913,7 +981,14 @@ function ChatPageInner() { }`} > {/* Main chat area */} -
+
{/* Mobile toolbar — session list + optional title + agent profile */} {isMobile && (
@@ -1031,6 +1106,7 @@ function ChatPageInner() { forkDisabledHint={forkDisabledHint} onAcpPermissionSelect={handleAcpPermissionSelect} onHitlDecision={handleHitlDecision} + onTurnRailVisibilityChange={setTurnRailVisible} onOpenBrowser={ hasBrowserTool && !isMobile ? openBrowserTab : undefined } @@ -1048,7 +1124,8 @@ function ChatPageInner() { {!isMobile && !dockOpen && !agentProfileOpen && - !workspaceDrawerOpen && ( + !workspaceDrawerOpen && + !trajectoryDrawerOpen && (
{/* PWA install first when available — same column as browser / experts. */} @@ -1142,6 +1219,34 @@ function ChatPageInner() { )} + {trajectoryEnabled && ( + + + + + + )} void handleToggleBrowserPanel()} aria-label={t("chat.openBrowser")} > @@ -1206,6 +1311,7 @@ function ChatPageInner() { })), ) } + onDismiss={() => handleAskDismiss(pendingAsk.actions)} />
@@ -1288,6 +1394,14 @@ function ChatPageInner() { /> )} + {trajectoryEnabled && ( + setTrajectoryDrawerOpen(false)} + /> + )}
diff --git a/dashboard/src/pages/Chat/utils/chromeInstallGate.test.ts b/dashboard/src/pages/Chat/utils/chromeInstallGate.test.ts new file mode 100644 index 00000000..17f897e9 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/chromeInstallGate.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + shouldJumpToChromeInstall, + WORKBENCH_BROWSER_PATH, +} from "./chromeInstallGate"; + +describe("shouldJumpToChromeInstall", () => { + it("redirects when browsers_ok is missing or false", () => { + expect(shouldJumpToChromeInstall({})).toBe(true); + expect(shouldJumpToChromeInstall({ browsers_ok: false })).toBe(true); + }); + + it("stays in chat when Chrome is available", () => { + expect(shouldJumpToChromeInstall({ browsers_ok: true })).toBe(false); + }); + + it("points at the workbench browser tab", () => { + expect(WORKBENCH_BROWSER_PATH).toBe("/workbench/browser"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/chromeInstallGate.ts b/dashboard/src/pages/Chat/utils/chromeInstallGate.ts new file mode 100644 index 00000000..adc9e3e0 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/chromeInstallGate.ts @@ -0,0 +1,9 @@ +/** Workbench tab that hosts Playwright Chromium install. */ +export const WORKBENCH_BROWSER_PATH = "/workbench/browser"; + +/** Host has no launchable Chrome/Chromium (`GET /browser/env-status`). */ +export function shouldJumpToChromeInstall(env: { + browsers_ok?: boolean; +}): boolean { + return env.browsers_ok !== true; +} diff --git a/dashboard/src/pages/Chat/utils/summarizeHitlAction.test.ts b/dashboard/src/pages/Chat/utils/summarizeHitlAction.test.ts new file mode 100644 index 00000000..d6cc3891 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/summarizeHitlAction.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import zh from "../../../locales/zh.json"; +import { summarizeHitlAction, type HitlTranslate } from "./summarizeHitlAction"; + +function lookup(bundle: unknown, key: string): string | undefined { + let node: unknown = bundle; + for (const part of key.split(".")) { + if (!node || typeof node !== "object") return undefined; + node = (node as Record)[part]; + } + return typeof node === "string" ? node : undefined; +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + name in vars ? String(vars[name]) : `{{${name}}}`, + ); +} + +/** Mimic i18next: missing keys return the key; `{{name}}` is interpolated. */ +function tFrom(bundle: unknown): HitlTranslate { + return (key, options) => { + const vars = + options && typeof options === "object" + ? (options as Record) + : {}; + const fallback = typeof options === "string" ? options : undefined; + const found = lookup(bundle, key); + if (found === undefined) return fallback ?? key; + return interpolate(found, vars); + }; +} + +const tZh = tFrom(zh); + +describe("summarizeHitlAction", () => { + it("explains browser_use dom_tree in Chinese instead of dumping JSON", () => { + const view = summarizeHitlAction( + "browser_use", + { action: "dom_tree", level: "interactive" }, + tZh, + "使用浏览器", + ); + + expect(view.toolLabel).toBe("使用浏览器"); + expect(view.summary).toBe("获取当前网页的可交互元素结构"); + expect(view.rows).toEqual([ + { label: "操作", value: "获取网页结构" }, + { label: "范围", value: "仅交互元素" }, + ]); + const blob = [view.summary, ...view.rows.map((row) => row.value)].join(" "); + expect(blob).not.toContain('"action"'); + expect(blob).not.toContain("{"); + }); + + it("puts the target URL into the navigate summary", () => { + const view = summarizeHitlAction( + "browser_use", + { action: "navigate", url: "https://news.example.com" }, + tZh, + "使用浏览器", + ); + + expect(view.summary).toBe("打开网页 https://news.example.com"); + expect(view.rows).toEqual([ + { label: "操作", value: "打开网页" }, + { label: "网址", value: "https://news.example.com", mono: true }, + ]); + }); + + it("explains a shell command instead of wrapping it in JSON", () => { + const view = summarizeHitlAction( + "execute", + { command: "ls -la inbound" }, + tZh, + "执行指令", + ); + + expect(view.summary).toBe("执行命令:ls -la inbound"); + expect(view.rows).toEqual([ + { label: "命令", value: "ls -la inbound", mono: true }, + ]); + }); + + it("prefers the action description when the model already explained it", () => { + const view = summarizeHitlAction( + "custom_plugin_tool", + { foo_bar: "secret.txt" }, + tZh, + "custom_plugin_tool", + "读取工作区里的密钥文件", + ); + + expect(view.summary).toBe("读取工作区里的密钥文件"); + expect(view.rows).toEqual([{ label: "Foo bar", value: "secret.txt" }]); + }); + + it("ignores the English HITL template and explains write_file in Chinese", () => { + const view = summarizeHitlAction( + "write_file", + { + file_path: "/.octop/workspaces/J7Y3TW/test.txt", + content: "Hello, this is a test file!\nCreated at: 2025-01-25\n", + }, + tZh, + "写入文件", + "Tool execution requires approval Tool: write_file Args: {'file_path': '/.octop/workspaces/J7Y3TW/test.txt', 'content': 'Hello, this is a test file!\\nCreated at: 2025-01-25\\n'}", + ); + + expect(view.summary).toBe("写入文件 /.octop/workspaces/J7Y3TW/test.txt"); + expect(view.summary).not.toContain("Tool execution"); + expect(view.rows).toEqual([ + { + label: "文件路径", + value: "/.octop/workspaces/J7Y3TW/test.txt", + mono: true, + }, + { + label: "内容", + value: "Hello, this is a test file!\nCreated at: 2025-01-25\n", + }, + ]); + }); + + it("omits server-owned profile and skips empty args", () => { + const view = summarizeHitlAction( + "browser_use", + { action: "screenshot", profile: "user-7" }, + tZh, + "使用浏览器", + ); + + expect(view.summary).toBe("截取当前网页截图"); + expect(view.rows.map((row) => row.label)).toEqual(["操作"]); + expect(view.rows.some((row) => row.value === "user-7")).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/summarizeHitlAction.ts b/dashboard/src/pages/Chat/utils/summarizeHitlAction.ts new file mode 100644 index 00000000..db543e74 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/summarizeHitlAction.ts @@ -0,0 +1,270 @@ +/** + * Turn a HITL tool-call into a short explanation plus labelled rows. + * Approval cards should never dump raw JSON as the primary view. + */ + +export interface HitlArgRow { + label: string; + value: string; + mono?: boolean; +} + +export interface HitlActionView { + toolLabel: string; + summary: string; + rows: HitlArgRow[]; +} + +export type HitlTranslate = ( + key: string, + options?: string | Record, +) => string; + +const ALWAYS_HIDDEN = new Set(["session_id", "profile"]); +const TRUNCATE_KEYS = new Set([ + "content", + "body", + "html", + "text", + "old_string", + "new_string", + "code", + "script", +]); +const BROWSER_TOOLS = new Set(["browser_use", "browser_control"]); +const SHELL_TOOLS = new Set(["execute", "bash", "run_terminal_cmd"]); +const MONO_KEYS = new Set([ + "command", + "cmd", + "path", + "file", + "file_path", + "url", + "code", + "script", + "selector", + "pattern", +]); +const MAX_TRUNCATED = 280; +const MAX_HARD = 2000; + +const MACHINE_HITL_DESC = /tool execution requires approval/i; +const TOOL_ARGS_DUMP = /\bTool:\s+\S[\s\S]*\bArgs:\s*[{'"]/; + +/** True when harness/langgraph stuffed a raw English args dump into ``description``. */ +export function isMachineHitlDescription(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return MACHINE_HITL_DESC.test(trimmed) || TOOL_ARGS_DUMP.test(trimmed); +} + +function resolve( + t: HitlTranslate, + key: string, + fallback: string, + vars?: Record, +): string { + const result = vars + ? t(key, { defaultValue: fallback, ...vars }) + : t(key, fallback); + if (!result || result === key) return fallback; + return result; +} + +export function humanizeArgKey(key: string): string { + const cleaned = key.replace(/[_-]+/g, " ").trim(); + if (!cleaned) return key; + return cleaned[0].toUpperCase() + cleaned.slice(1); +} + +function isEmpty(value: unknown): boolean { + return value === null || value === undefined || value === ""; +} + +function shouldHide(key: string, value: unknown): boolean { + if (ALWAYS_HIDDEN.has(key) || isEmpty(value)) return true; + return false; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1))}…`; +} + +function formatPlain(value: unknown, t: HitlTranslate): string { + if (typeof value === "boolean") { + return resolve( + t, + value ? "chat.hitl.bool.true" : "chat.hitl.bool.false", + value ? "Yes" : "No", + ); + } + if (typeof value === "number") return String(value); + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value + .filter((item) => !isEmpty(item)) + .map((item) => formatPlain(item, t)) + .join(", "); + } + if (value && typeof value === "object") { + return Object.entries(value as Record) + .filter(([k, v]) => !shouldHide(k, v)) + .map(([k, v]) => `${humanizeArgKey(k)}: ${formatPlain(v, t)}`) + .join("; "); + } + return String(value); +} + +function formatKnownString( + toolName: string, + key: string, + raw: string, + t: HitlTranslate, +): string { + if (BROWSER_TOOLS.has(toolName) || key === "action") { + if (key === "action") { + return resolve(t, `chat.hitl.browser.actions.${raw}`, raw); + } + if (key === "level") { + return resolve(t, `chat.hitl.browser.levels.${raw}`, raw); + } + if (key === "direction") { + return resolve(t, `chat.hitl.browser.directions.${raw}`, raw); + } + } + return raw; +} + +function formatArgValue( + toolName: string, + key: string, + value: unknown, + t: HitlTranslate, +): string { + if (typeof value === "string") { + const labelled = formatKnownString(toolName, key, value, t); + const max = TRUNCATE_KEYS.has(key) ? MAX_TRUNCATED : MAX_HARD; + return truncate(labelled, max); + } + const plain = formatPlain(value, t); + const max = TRUNCATE_KEYS.has(key) ? MAX_TRUNCATED : MAX_HARD; + return truncate(plain, max); +} + +function argLabel(key: string, t: HitlTranslate): string { + return resolve(t, `chat.hitl.args.${key}`, humanizeArgKey(key)); +} + +function stringArg( + args: Record, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function browserSummary( + args: Record, + t: HitlTranslate, + toolLabel: string, +): string { + const action = stringArg(args, "action") ?? ""; + const url = stringArg(args, "url"); + const level = stringArg(args, "level"); + if ((action === "navigate" || action === "open") && url) { + return resolve(t, "chat.hitl.summaries.openUrl", `Open ${url}`, { url }); + } + if (action === "new_tab" && url) { + return resolve(t, "chat.hitl.summaries.openUrl", `Open ${url}`, { url }); + } + if (action === "screenshot") { + return resolve( + t, + "chat.hitl.summaries.screenshot", + "Take a screenshot of the current page", + ); + } + if (action === "dom_tree" && level === "interactive") { + return resolve( + t, + "chat.hitl.summaries.domTreeInteractive", + "Read the interactive structure of the current page", + ); + } + if (action === "dom_tree") { + return resolve( + t, + "chat.hitl.summaries.domTree", + "Read the structure of the current page", + ); + } + if (action) { + return resolve(t, `chat.hitl.browser.actions.${action}`, toolLabel); + } + return toolLabel; +} + +function buildSummary( + name: string, + args: Record, + t: HitlTranslate, + toolLabel: string, + description?: string, +): string { + const explained = description?.trim(); + if (explained && !isMachineHitlDescription(explained)) return explained; + if (BROWSER_TOOLS.has(name)) return browserSummary(args, t, toolLabel); + const command = stringArg(args, "command", "cmd"); + if (SHELL_TOOLS.has(name) && command) { + return resolve(t, "chat.hitl.summaries.runCommand", `Run: ${command}`, { + command, + }); + } + const path = stringArg(args, "path", "file", "file_path"); + if (name === "write_file" && path) { + return resolve(t, "chat.hitl.summaries.writeFile", `Write ${path}`, { + path, + }); + } + if (name === "read_file" && path) { + return resolve(t, "chat.hitl.summaries.readFile", `Read ${path}`, { path }); + } + if (name === "edit_file" && path) { + return resolve(t, "chat.hitl.summaries.editFile", `Edit ${path}`, { path }); + } + const url = stringArg(args, "url"); + if (name === "web_fetch" && url) { + return resolve(t, "chat.hitl.summaries.fetchUrl", `Fetch ${url}`, { url }); + } + return toolLabel; +} + +export function summarizeHitlAction( + name: string, + args: Record | undefined, + t: HitlTranslate, + toolLabel: string, + description?: string, +): HitlActionView { + const safeArgs = args && typeof args === "object" ? args : {}; + const rows: HitlArgRow[] = []; + for (const [key, value] of Object.entries(safeArgs)) { + if (shouldHide(key, value)) continue; + const formatted = formatArgValue(name, key, value, t); + if (!formatted) continue; + rows.push({ + label: argLabel(key, t), + value: formatted, + ...(MONO_KEYS.has(key) ? { mono: true } : {}), + }); + } + return { + toolLabel, + summary: buildSummary(name, safeArgs, t, toolLabel, description), + rows, + }; +} diff --git a/dashboard/src/pages/Chat/utils/trajectoryModel.test.ts b/dashboard/src/pages/Chat/utils/trajectoryModel.test.ts new file mode 100644 index 00000000..417ef8c5 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/trajectoryModel.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "vitest"; +import type { + TrajectoryEvent, + TrajectoryMetrics, +} from "../../../api/modules/trajectory"; +import { + collapseCalls, + collapseCallRows, + collapseTurns, + ensureToolCallParents, + filterRows, + formatCollapsedToolCalls, + formatDurationMs, + kindLabelFor, + laneForKind, + toLedgerRow, + visibleMetrics, + coerceToolResultText, +} from "./trajectoryModel"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +describe("laneForKind", () => { + it("maps user and system to the input lane", () => { + expect(laneForKind("user")).toBe("input"); + expect(laneForKind("system")).toBe("input"); + }); + + it("maps assistant and compacted to the model lane", () => { + expect(laneForKind("assistant")).toBe("model"); + expect(laneForKind("compacted")).toBe("model"); + }); + + it("maps context to the input lane (DSH parity)", () => { + expect(laneForKind("context")).toBe("input"); + }); + + it("maps tool to the tools lane", () => { + expect(laneForKind("tool")).toBe("tools"); + }); + + it("maps unknown kinds to the input lane", () => { + expect(laneForKind("unknown")).toBe("input"); + }); +}); + +describe("toLedgerRow", () => { + it("copies id, summary, and error flag from the event", () => { + const row = toLedgerRow( + event({ + event_id: "e1", + kind: "user", + summary: "hello", + is_error: true, + }), + ); + expect(row).toMatchObject({ + id: "e1", + kind: "user", + summary: "hello", + isError: true, + }); + }); + + it("labels assistant rows as Request #N when request_seq is set", () => { + const row = toLedgerRow( + event({ + event_id: "a1", + kind: "assistant", + request_seq: 3, + summary: "thinking…", + }), + ); + expect(row.title).toBe("Request #3"); + expect(row.requestSeq).toBe(3); + }); + + it("uses the tool name as the row title", () => { + const row = toLedgerRow( + event({ + event_id: "t1", + kind: "tool", + summary: "tool read_file", + payload: { name: "read_file" }, + }), + ); + expect(row.title).toBe("read_file"); + }); + + it("unwraps MCP content-block tool results for the ledger", () => { + const row = toLedgerRow( + event({ + event_id: "t2", + kind: "tool", + summary: "tool list_projects", + payload: { + name: "list_projects", + result: [ + { + type: "text", + text: '{\n "id": "1",\n "name": "工作"\n}', + }, + ], + }, + }), + ); + expect(row.toolResult).toContain('"name": "工作"'); + expect(row.toolResult).not.toContain('"type": "text"'); + }); + + it("omits requestSeq when the event has none", () => { + const row = toLedgerRow(event({ event_id: "u1", kind: "user" })); + expect(row.requestSeq).toBeUndefined(); + }); +}); + +describe("coerceToolResultText", () => { + it("pretty-prints unwrapped MCP text JSON", () => { + const text = coerceToolResultText([ + { type: "text", text: '{"id":"1","name":"工作"}' }, + ]); + expect(text).toContain('"name": "工作"'); + expect(text).not.toContain("type"); + }); +}); + +describe("filterRows", () => { + const rows = [ + { + id: "1", + kind: "tool", + kindLabel: "TOOL" as const, + title: "read_file", + summary: "open a.py", + content: "open a.py", + toolArgs: null, + toolResult: null, + isError: false, + }, + { + id: "2", + kind: "assistant", + kindLabel: "ASSISTANT" as const, + title: "Request #1", + summary: "I'll look at the source", + content: "I'll look at the source", + toolArgs: null, + toolResult: null, + requestSeq: 1, + isError: false, + }, + ]; + + it("returns every row when the query is empty or whitespace", () => { + expect(filterRows(rows, "")).toEqual(rows); + expect(filterRows(rows, " ")).toEqual(rows); + }); + + it("matches title, summary, or kind case-insensitively", () => { + expect(filterRows(rows, "READ").map((row) => row.id)).toEqual(["1"]); + expect(filterRows(rows, "source")).toHaveLength(1); + expect(filterRows(rows, "source")[0].id).toBe("2"); + expect(filterRows(rows, "tool").map((row) => row.id)).toEqual(["1"]); + }); + + it("returns no rows when nothing matches", () => { + expect(filterRows(rows, "xyz")).toEqual([]); + }); +}); + +describe("visibleMetrics", () => { + const base: TrajectoryMetrics = { + turns: 2, + steps: 5, + llm_duration_ms: null, + tool_duration_ms: 40, + ttft_avg_ms: null, + tok_per_s: 0, + cache_hit_ratio: null, + input_tokens: 10, + output_tokens: null, + cache_read_tokens: null, + }; + + it("omits null metric fields and keeps zeros", () => { + const entries = visibleMetrics(base); + expect(entries.map((entry) => entry.key)).toEqual([ + "turns", + "steps", + "tool_duration_ms", + "tok_per_s", + "input_tokens", + ]); + expect(entries.find((entry) => entry.key === "tok_per_s")?.value).toBe(0); + expect(entries.some((entry) => entry.key === "llm_duration_ms")).toBe( + false, + ); + }); +}); + +describe("collapseTurns", () => { + it("groups consecutive events that share a turn_id", () => { + const groups = collapseTurns([ + event({ event_id: "1", kind: "user", turn_id: "t1" }), + event({ event_id: "2", kind: "assistant", turn_id: "t1" }), + event({ event_id: "3", kind: "user", turn_id: "t2" }), + ]); + expect(groups.map((group) => group.map((ev) => ev.event_id))).toEqual([ + ["1", "2"], + ["3"], + ]); + }); + + it("falls back to USER boundaries when turn_id is missing", () => { + const groups = collapseTurns([ + event({ event_id: "s", kind: "system", turn_id: null }), + event({ event_id: "u1", kind: "user", turn_id: null }), + event({ event_id: "a1", kind: "assistant", turn_id: null }), + event({ event_id: "t1", kind: "tool", turn_id: null }), + event({ event_id: "u2", kind: "user", turn_id: null }), + event({ event_id: "a2", kind: "assistant", turn_id: null }), + ]); + expect(groups.map((group) => group.map((ev) => ev.event_id))).toEqual([ + ["s"], + ["u1", "a1", "t1"], + ["u2", "a2"], + ]); + }); +}); + +describe("collapseCalls", () => { + it("folds tool rows under the preceding assistant and drops orphan tools", () => { + const groups = collapseCalls([ + event({ event_id: "1", kind: "tool" }), + event({ event_id: "2", kind: "tool" }), + event({ event_id: "3", kind: "assistant" }), + event({ event_id: "4", kind: "tool" }), + event({ event_id: "5", kind: "tool" }), + event({ event_id: "6", kind: "user" }), + ]); + expect(groups.map((group) => group.map((ev) => ev.event_id))).toEqual([ + ["3", "4", "5"], + ["6"], + ]); + }); +}); + +describe("collapseCallRows", () => { + it("keeps the assistant and inserts a separate DSH summary row", () => { + const rows = collapseCallRows([ + event({ + event_id: "a", + kind: "assistant", + summary: "(tool call only)", + payload: { tool_call_only: true, content: "" }, + }), + event({ + event_id: "t1", + kind: "tool", + payload: { name: "todo_write" }, + }), + event({ + event_id: "t2", + kind: "tool", + payload: { name: "bash" }, + }), + event({ + event_id: "t3", + kind: "tool", + payload: { name: "bash" }, + }), + event({ + event_id: "t4", + kind: "tool", + payload: { name: "glob" }, + }), + event({ + event_id: "u", + kind: "user", + summary: "next", + }), + ]); + expect(rows.map((row) => row.event_id)).toEqual([ + "a", + "a__assistant_summary", + "u", + ]); + expect(rows[0]?.payload.tool_call_only).toBe(true); + expect(rows[0]?.summary).toBe("(tool call only)"); + expect(rows[1]?.payload.collapsed_summary).toBe(true); + expect(rows[1]?.payload.content).toBe( + "4 tool calls · todo_write, bash, glob", + ); + }); + + it("formats singular tool call counts", () => { + expect(formatCollapsedToolCalls(1, ["read"])).toBe("1 tool call · read"); + }); +}); + +describe("ensureToolCallParents", () => { + it("inserts a tool-call-only assistant before orphan tool bursts", () => { + const rows = ensureToolCallParents([ + event({ event_id: "u", kind: "user" }), + event({ event_id: "t1", kind: "tool", summary: "tool ls" }), + event({ event_id: "t2", kind: "tool", summary: "tool ls" }), + event({ + event_id: "a", + kind: "assistant", + summary: "done", + payload: { content: "done" }, + }), + ]); + expect(rows.map((row) => row.kind)).toEqual([ + "user", + "assistant", + "tool", + "tool", + "assistant", + ]); + expect(rows[1]?.payload.tool_call_only).toBe(true); + expect(rows[1]?.summary).toBe("(tool call only)"); + }); + + it("does not duplicate parents when an assistant already precedes tools", () => { + const rows = ensureToolCallParents([ + event({ + event_id: "a", + kind: "assistant", + summary: "(tool call only)", + payload: { tool_call_only: true }, + }), + event({ event_id: "t1", kind: "tool" }), + ]); + expect(rows.map((row) => row.event_id)).toEqual(["a", "t1"]); + }); +}); + +describe("formatDurationMs", () => { + it("formats compact DSH-style durations", () => { + expect(formatDurationMs(45)).toBe("45ms"); + expect(formatDurationMs(1200)).toBe("1.2s"); + expect(formatDurationMs(12_500)).toBe("13s"); + expect(formatDurationMs(74_370)).toBe("1m14s"); + }); +}); + +describe("kindLabelFor", () => { + it("maps kinds to DSH uppercase labels", () => { + expect(kindLabelFor("assistant")).toBe("ASSISTANT"); + expect(kindLabelFor("tool")).toBe("TOOL"); + expect(kindLabelFor("user")).toBe("USER"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/trajectoryModel.ts b/dashboard/src/pages/Chat/utils/trajectoryModel.ts new file mode 100644 index 00000000..c48c449f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/trajectoryModel.ts @@ -0,0 +1,576 @@ +import type { + TrajectoryEvent, + TrajectoryKind, + TrajectoryMetrics, +} from "../../../api/modules/trajectory"; + +export type TrajectoryLane = "input" | "model" | "tools"; + +export type TrajectoryKindLabel = + | "USER" + | "ASSISTANT" + | "TOOL" + | "CONTEXT" + | "SYSTEM" + | "COMPACTED" + | "UNKNOWN"; + +export interface TrajectoryLedgerRow { + id: string; + kind: string; + kindLabel: TrajectoryKindLabel; + title: string; + summary: string; + /** Single-line primary text shown in the content cell. */ + content: string; + /** Tool args JSON (or preview) when kind is tool. */ + toolArgs: string | null; + /** Tool result preview when kind is tool. */ + toolResult: string | null; + /** ASSISTANT parent that owns a tool burst (DSH “tool call only”). */ + toolCallOnly?: boolean; + /** DSH collapsed summary row (no kind badge; leading ellipsis). */ + collapsedSummary?: boolean; + collapsedSummaryKind?: "assistant" | "turn"; + /** Parent assistant/turn head id when this is a collapsed summary row. */ + collapsedParentId?: string; + requestSeq?: number; + isError: boolean; +} + +/** Match DeepSeek Harness ui-trajectory lanes: Input / Model / Tools. */ +const INPUT_KINDS = new Set(["user", "system", "context", "unknown"]); +const MODEL_KINDS = new Set(["assistant", "compacted"]); + +export function laneForKind(kind: string): TrajectoryLane { + if (kind === "tool") return "tools"; + if (MODEL_KINDS.has(kind)) return "model"; + if (INPUT_KINDS.has(kind)) return "input"; + return "input"; +} + +export function kindLabelFor(kind: string): TrajectoryKindLabel { + switch (kind) { + case "user": + return "USER"; + case "assistant": + return "ASSISTANT"; + case "tool": + return "TOOL"; + case "context": + return "CONTEXT"; + case "system": + return "SYSTEM"; + case "compacted": + return "COMPACTED"; + default: + return "UNKNOWN"; + } +} + +function titleForEvent(event: TrajectoryEvent): string { + if (event.payload.collapsed_summary === true) { + return payloadString(event.payload, "content") || event.summary || ""; + } + if (event.kind === "assistant" && event.payload.tool_call_only === true) { + return event.summary || "(tool call only)"; + } + if (event.kind === "assistant" && event.request_seq != null) { + return `Request #${event.request_seq}`; + } + if (event.kind === "tool") { + const name = event.payload.name; + return typeof name === "string" && name ? name : "tool"; + } + if (event.kind === "context") { + const label = event.payload.label; + return typeof label === "string" && label ? label : "context"; + } + if (event.kind === "system") { + const label = event.payload.label; + return typeof label === "string" && label ? label : "system"; + } + return event.kind; +} + +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function payloadString( + payload: Record, + key: string, +): string | null { + const value = payload[key]; + if (typeof value === "string" && value.trim()) return value; + return null; +} + +function isMcpTextBlock( + value: unknown, +): value is { type?: string; text?: unknown } { + return ( + typeof value === "object" && + value != null && + ("text" in value || (value as { type?: unknown }).type === "text") + ); +} + +function prettyIfJson(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return null; + } +} + +/** Unwrap MCP `[{type,text}]` / JSON strings into the tool's actual return text. */ +export function coerceToolResultText(value: unknown): string | null { + if (value == null) return null; + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + const withoutEllipsis = trimmed.endsWith("…") + ? trimmed.slice(0, -1).trim() + : trimmed; + if (withoutEllipsis.startsWith("[") || withoutEllipsis.startsWith("{")) { + try { + const nested = coerceToolResultText(JSON.parse(withoutEllipsis)); + if (nested) return nested; + } catch { + const match = /"text"\s*:\s*"((?:\\.|[^"\\])*)/.exec(trimmed); + if (match?.[1] != null) { + try { + return JSON.parse(`"${match[1]}"`) as string; + } catch { + return match[1] + .replace(/\\n/g, "\n") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + } + } + } + } + return prettyIfJson(trimmed) ?? value; + } + + if (Array.isArray(value)) { + if (value.length > 0 && value.every(isMcpTextBlock)) { + const joined = value + .map((block) => + typeof block.text === "string" + ? block.text + : block.text == null + ? "" + : JSON.stringify(block.text), + ) + .filter((part) => part.length > 0) + .join("\n\n"); + if (!joined) return null; + return prettyIfJson(joined) ?? joined; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + if (typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +} + +/** Pretty-print tool args for the Payload inspector tab. */ +export function coerceToolArgsText(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + return prettyIfJson(trimmed) ?? value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function formatToolArgs(payload: Record): string | null { + const args = payload.args ?? payload.arguments ?? payload.input; + if (args == null) return null; + const text = coerceToolArgsText(args); + return text ? oneLine(text) : null; +} + +function formatToolResult(payload: Record): string | null { + const text = + coerceToolResultText(payload.result) ?? + coerceToolResultText(payload.output) ?? + coerceToolResultText(payload.content); + return text ? oneLine(text) : null; +} + +function contentForEvent(event: TrajectoryEvent): string { + if (event.kind === "tool") { + return oneLine(event.summary) || titleForEvent(event); + } + const content = payloadString(event.payload, "content"); + if (content) return oneLine(content); + return oneLine(event.summary); +} + +export function toLedgerRow(event: TrajectoryEvent): TrajectoryLedgerRow { + const toolCallOnly = + event.kind === "assistant" && event.payload.tool_call_only === true; + const collapsedSummary = event.payload.collapsed_summary === true; + const collapsedKind = event.payload.collapsed_summary_kind; + const parentId = event.payload.parent_event_id; + const row: TrajectoryLedgerRow = { + id: event.event_id, + kind: event.kind, + kindLabel: kindLabelFor(event.kind), + title: titleForEvent(event), + summary: event.summary, + content: contentForEvent(event), + toolArgs: event.kind === "tool" ? formatToolArgs(event.payload) : null, + toolResult: event.kind === "tool" ? formatToolResult(event.payload) : null, + isError: event.is_error, + }; + if (toolCallOnly) { + row.toolCallOnly = true; + } + if (collapsedSummary) { + row.collapsedSummary = true; + if (collapsedKind === "assistant" || collapsedKind === "turn") { + row.collapsedSummaryKind = collapsedKind; + } + if (typeof parentId === "string" && parentId) { + row.collapsedParentId = parentId; + } + } + if (event.request_seq != null) { + row.requestSeq = event.request_seq; + } + return row; +} + +export function filterRows( + rows: TrajectoryLedgerRow[], + query: string, +): TrajectoryLedgerRow[] { + const needle = query.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((row) => { + return ( + row.title.toLowerCase().includes(needle) || + row.summary.toLowerCase().includes(needle) || + row.content.toLowerCase().includes(needle) || + row.kind.toLowerCase().includes(needle) || + row.kindLabel.toLowerCase().includes(needle) || + (row.toolArgs?.toLowerCase().includes(needle) ?? false) || + (row.toolResult?.toLowerCase().includes(needle) ?? false) + ); + }); +} + +export function collapseTurns(events: TrajectoryEvent[]): TrajectoryEvent[][] { + const groups: TrajectoryEvent[][] = []; + for (const event of events) { + const last = groups[groups.length - 1]; + if (!last) { + groups.push([event]); + continue; + } + const lastTurn = + last.find((row) => row.turn_id)?.turn_id ?? last[0]?.turn_id ?? null; + if (event.turn_id && lastTurn && event.turn_id === lastTurn) { + last.push(event); + continue; + } + if (event.turn_id && lastTurn && event.turn_id !== lastTurn) { + groups.push([event]); + continue; + } + // Harness often omits turn_id — fall back to USER boundaries so Turns + // collapse still groups a user message with its following context/tools. + if (event.kind === "user") { + groups.push([event]); + continue; + } + if (event.turn_id && !lastTurn) { + groups.push([event]); + continue; + } + last.push(event); + } + return groups; +} + +/** Fold tool rows under the preceding assistant (DSH “Calls”). Orphan tools drop. */ +export function collapseCalls(events: TrajectoryEvent[]): TrajectoryEvent[][] { + const groups: TrajectoryEvent[][] = []; + for (const event of events) { + const last = groups[groups.length - 1]; + if (event.kind === "tool") { + if (last?.[0]?.kind === "assistant") { + last.push(event); + } + continue; + } + groups.push([event]); + } + return groups; +} + +function toolNameOf(event: TrajectoryEvent): string | null { + const name = event.payload.name; + return typeof name === "string" && name.trim() ? name.trim() : null; +} + +/** Unique tool names in first-seen order (DSH collapsed-call summary). */ +export function uniqueToolNames(tools: readonly TrajectoryEvent[]): string[] { + const names: string[] = []; + for (const tool of tools) { + const name = toolNameOf(tool); + if (name && !names.includes(name)) names.push(name); + } + return names; +} + +/** + * DSH assistant-tool summary body (ellipsis is rendered by the ledger): + * `5 tool calls · todo_write, bash, glob` + */ +export function formatCollapsedToolCalls( + count: number, + names: readonly string[], +): string { + if (count <= 0) return ""; + const unit = count === 1 ? "tool call" : "tool calls"; + const namePart = names.length > 0 ? ` · ${names.join(", ")}` : ""; + return `${count} ${unit}${namePart}`; +} + +/** DSH turn summary body: `13 steps · 44 tool calls`. */ +export function formatCollapsedTurn(steps: number, toolCalls: number): string { + const stepsPart = steps === 1 ? "1 step" : `${steps} steps`; + const toolsPart = toolCalls === 1 ? "1 tool call" : `${toolCalls} tool calls`; + return `${stepsPart} · ${toolsPart}`; +} + +function collapsedSummaryEvent( + head: TrajectoryEvent, + summary: string, + kind: "assistant" | "turn", +): TrajectoryEvent { + return { + event_id: `${head.event_id}__${kind}_summary`, + thread_id: head.thread_id, + agent_id: head.agent_id, + seq: head.seq, + ts: head.ts, + kind: "assistant", + turn_id: head.turn_id, + request_seq: head.request_seq, + is_error: false, + summary, + payload: { + collapsed_summary: true, + collapsed_summary_kind: kind, + content: summary, + parent_event_id: head.event_id, + }, + }; +} + +/** + * DSH “Calls”: keep the assistant row, hide following TOOL rows, and insert a + * separate summary row (`… N tool calls · names`) with no kind badge. + */ +export function collapseCallRows( + events: TrajectoryEvent[], + formatSummary: ( + count: number, + names: readonly string[], + ) => string = formatCollapsedToolCalls, + collapsedAssistantIds?: ReadonlySet | null, +): TrajectoryEvent[] { + const out: TrajectoryEvent[] = []; + for (let index = 0; index < events.length; index += 1) { + const event = events[index]; + if (event == null) continue; + out.push(event); + if (event.kind !== "assistant") continue; + if ( + collapsedAssistantIds != null && + !collapsedAssistantIds.has(event.event_id) + ) { + continue; + } + + const tools: TrajectoryEvent[] = []; + let cursor = index + 1; + while (cursor < events.length && events[cursor]?.kind === "tool") { + const tool = events[cursor]; + if (tool) tools.push(tool); + cursor += 1; + } + if (tools.length === 0) continue; + + out.push( + collapsedSummaryEvent( + event, + formatSummary(tools.length, uniqueToolNames(tools)), + "assistant", + ), + ); + index = cursor - 1; + } + return out; +} + +/** + * DSH “Turns”: keep the first row of each turn, hide the rest, and insert + * `… N steps · M tool calls` as a separate summary row. + */ +export function collapseTurnRows( + events: TrajectoryEvent[], + formatSummary: ( + steps: number, + toolCalls: number, + ) => string = formatCollapsedTurn, +): TrajectoryEvent[] { + const out: TrajectoryEvent[] = []; + const groups = collapseTurns(events); + for (const group of groups) { + const head = group[0]; + if (head == null) continue; + if (group.length <= 1) { + out.push(head); + continue; + } + const rest = group.slice(1); + const toolCalls = rest.filter((event) => event.kind === "tool").length; + const requestSeqs = new Set( + rest + .map((event) => event.request_seq) + .filter((value): value is number => value != null), + ); + const steps = + requestSeqs.size > 0 + ? requestSeqs.size + : rest.filter((event) => event.kind !== "tool").length; + out.push(head); + out.push( + collapsedSummaryEvent(head, formatSummary(steps, toolCalls), "turn"), + ); + } + return out; +} + +/** Assistants that currently own at least one following tool row. */ +export function collapsibleAssistantIds( + events: readonly TrajectoryEvent[], +): string[] { + const ids: string[] = []; + for (let index = 0; index < events.length; index += 1) { + const event = events[index]; + if (event?.kind === "assistant" && events[index + 1]?.kind === "tool") { + ids.push(event.event_id); + } + } + return ids; +} + +/** + * Insert a synthetic ASSISTANT “(tool call only)” parent before orphan tool + * bursts (DSH parity / historical rows recorded before the service fix). + * Skips when an assistant row already precedes the tools. + */ +export function ensureToolCallParents( + events: readonly TrajectoryEvent[], + label = "(tool call only)", +): TrajectoryEvent[] { + const out: TrajectoryEvent[] = []; + for (const event of events) { + if (event.kind === "tool") { + const prev = out[out.length - 1]; + // One synthetic parent per orphan burst: consecutive tools share it. + // Skip when an assistant already owns the burst, or when the previous + // row is already a tool under that parent. + if (prev == null || (prev.kind !== "assistant" && prev.kind !== "tool")) { + out.push({ + event_id: `${event.event_id}__tool_call_only`, + thread_id: event.thread_id, + agent_id: event.agent_id, + seq: event.seq, + ts: event.ts, + kind: "assistant", + turn_id: event.turn_id, + request_seq: event.request_seq, + is_error: false, + summary: label, + payload: { tool_call_only: true, content: "" }, + }); + } + } + out.push(event); + } + return out; +} + +const METRIC_KEYS: (keyof TrajectoryMetrics)[] = [ + "turns", + "steps", + "llm_duration_ms", + "tool_duration_ms", + "ttft_avg_ms", + "tok_per_s", + "cache_hit_ratio", + "input_tokens", + "output_tokens", + "cache_read_tokens", +]; + +export interface VisibleMetric { + key: keyof TrajectoryMetrics; + value: number; +} + +export function formatDurationMs(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds < 0) return "—"; + if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`; + if (milliseconds < 60_000) { + const seconds = milliseconds / 1000; + return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`; + } + const minutes = Math.floor(milliseconds / 60_000); + const seconds = Math.round((milliseconds % 60_000) / 1000); + return `${minutes}m${String(seconds).padStart(2, "0")}s`; +} + +export function visibleMetrics(metrics: TrajectoryMetrics): VisibleMetric[] { + const entries: VisibleMetric[] = []; + for (const key of METRIC_KEYS) { + const value = metrics[key]; + if (value != null) { + entries.push({ key, value }); + } + } + return entries; +} + +export type { TrajectoryEvent, TrajectoryKind, TrajectoryMetrics }; diff --git a/dashboard/src/pages/Chat/utils/trajectoryTimeline.test.ts b/dashboard/src/pages/Chat/utils/trajectoryTimeline.test.ts new file mode 100644 index 00000000..133c55d4 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/trajectoryTimeline.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from "vitest"; +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import { + deriveSwimlaneSpans, + orderedRange, + trajectoryFocusEventIds, + zoomDomain, + type SwimlaneSpan, +} from "./trajectoryTimeline"; + +function event( + overrides: Partial & + Pick, +): TrajectoryEvent { + return { + thread_id: "T1", + agent_id: "A1", + seq: 1, + ts: 1, + turn_id: null, + request_seq: null, + is_error: false, + summary: "", + payload: {}, + ...overrides, + }; +} + +describe("deriveSwimlaneSpans", () => { + it("returns no spans for an empty event list", () => { + expect(deriveSwimlaneSpans([], "sequence")).toEqual([]); + }); + + it("emits one discrete span per event in sequence mode (no lane merge)", () => { + const spans = deriveSwimlaneSpans( + [ + event({ event_id: "u", kind: "user" }), + event({ event_id: "a", kind: "assistant" }), + event({ event_id: "t1", kind: "tool" }), + event({ event_id: "t2", kind: "tool" }), + event({ event_id: "c", kind: "context" }), + ], + "sequence", + ); + expect( + spans.map((span) => ({ + lane: span.lane, + kind: span.kind, + eventIds: span.eventIds, + start: span.start, + end: span.end, + })), + ).toEqual([ + { + lane: "input", + kind: "user", + eventIds: ["u"], + start: 0, + end: 1, + }, + { + lane: "model", + kind: "assistant", + eventIds: ["a"], + start: 1, + end: 2, + }, + { + lane: "tools", + kind: "tool", + eventIds: ["t1"], + start: 2, + end: 3, + }, + { + lane: "tools", + kind: "tool", + eventIds: ["t2"], + start: 3, + end: 4, + }, + { + lane: "input", + kind: "context", + eventIds: ["c"], + start: 4, + end: 5, + }, + ]); + }); + + it("sizes spans from payload durations in duration mode", () => { + const spans = deriveSwimlaneSpans( + [ + event({ + event_id: "a", + kind: "assistant", + payload: { llm_duration_ms: 100 }, + }), + event({ + event_id: "t", + kind: "tool", + payload: { tool_duration_ms: 50 }, + }), + ], + "duration", + ); + expect(spans).toHaveLength(2); + expect(spans[0]).toMatchObject({ + lane: "model", + eventIds: ["a"], + start: 0, + end: 100, + }); + expect(spans[1]).toMatchObject({ + lane: "tools", + eventIds: ["t"], + start: 100, + end: 150, + }); + }); + + it("falls back to timestamp gaps in duration mode when payload lacks durations", () => { + const spans = deriveSwimlaneSpans( + [ + event({ event_id: "a", kind: "assistant", ts: 1 }), + event({ event_id: "t", kind: "tool", ts: 1.5 }), + event({ event_id: "u", kind: "user", ts: 2, summary: "hi" }), + ], + "duration", + ); + // Last event has no successor gap → content-size estimate (user "hi" → 42). + expect( + spans.map((span) => ({ id: span.id, start: span.start, end: span.end })), + ).toEqual([ + { id: "a", start: 0, end: 500 }, + { id: "t", start: 500, end: 1000 }, + { id: "u", start: 1000, end: 1042 }, + ]); + }); + + it("ignores sub-50ms timestamp gaps and estimates instead", () => { + const spans = deriveSwimlaneSpans( + [ + event({ + event_id: "a", + kind: "assistant", + ts: 1, + summary: "x".repeat(100), + }), + event({ event_id: "t", kind: "tool", ts: 1.01, summary: "tool" }), + ], + "duration", + ); + // 10ms gap is below the floor → estimate, not 10. + expect(spans[0]!.end - spans[0]!.start).toBeGreaterThan(10); + expect(spans[0]!.end - spans[0]!.start).toBe(120 + 100 * 2); + }); + + it("estimates duration when timestamps are equal so Duration differs from sequence", () => { + const events = [ + event({ event_id: "u", kind: "user", ts: 100, summary: "hi" }), + event({ + event_id: "a", + kind: "assistant", + ts: 100, + summary: "x".repeat(200), + }), + event({ + event_id: "t", + kind: "tool", + ts: 100, + summary: "tool", + payload: { name: "read", result: "y".repeat(50) }, + }), + ]; + const sequence = deriveSwimlaneSpans(events, "sequence"); + const duration = deriveSwimlaneSpans(events, "duration"); + expect(sequence.map((s) => s.end - s.start)).toEqual([1, 1, 1]); + expect(duration[1]!.end - duration[1]!.start).toBeGreaterThan( + duration[0]!.end - duration[0]!.start, + ); + expect(duration.map((s) => s.end - s.start)).not.toEqual([1, 1, 1]); + }); + + it("estimates duration from payload bulk when ts and duration fields are missing", () => { + const spans = deriveSwimlaneSpans( + [ + event({ + event_id: "u", + kind: "user", + ts: 0, + summary: "hi", + }), + event({ + event_id: "a", + kind: "assistant", + ts: 0, + summary: "x".repeat(200), + }), + event({ + event_id: "t", + kind: "tool", + ts: 0, + summary: "tool", + payload: { name: "read", result: "y".repeat(50) }, + }), + ], + "duration", + ); + expect(spans[0]!.end - spans[0]!.start).toBeLessThan( + spans[1]!.end - spans[1]!.start, + ); + expect(spans[2]!.end - spans[2]!.start).toBeGreaterThan( + spans[0]!.end - spans[0]!.start, + ); + const equal = deriveSwimlaneSpans( + [ + event({ event_id: "u", kind: "user", ts: 0, summary: "hi" }), + event({ + event_id: "a", + kind: "assistant", + ts: 0, + summary: "x".repeat(200), + }), + event({ + event_id: "t", + kind: "tool", + ts: 0, + summary: "tool", + payload: { name: "read", result: "y".repeat(50) }, + }), + ], + "sequence", + ); + expect(equal.every((span) => span.end - span.start === 1)).toBe(true); + }); + + it("uses timestamps in actual mode and keeps a visible last span", () => { + const spans = deriveSwimlaneSpans( + [ + event({ event_id: "u", kind: "user", ts: 10 }), + event({ event_id: "a", kind: "assistant", ts: 20 }), + event({ event_id: "t", kind: "tool", ts: 25 }), + ], + "actual", + ); + expect( + spans.map((span) => ({ + lane: span.lane, + start: span.start, + end: span.end, + })), + ).toEqual([ + { lane: "input", start: 10, end: 20 }, + { lane: "model", start: 20, end: 25 }, + { lane: "tools", start: 25, end: 26 }, + ]); + }); +}); + +describe("trajectory timeline domain helpers", () => { + it("orderedRange normalizes inverted drags", () => { + expect(orderedRange(5, 2)).toEqual({ start: 2, end: 5 }); + }); + + it("trajectoryFocusEventIds includes spans intersecting the range", () => { + const spans: SwimlaneSpan[] = [ + { + id: "a", + lane: "input", + kind: "user", + start: 0, + end: 1, + eventIds: ["a"], + isError: false, + }, + { + id: "b", + lane: "model", + kind: "assistant", + start: 1, + end: 2, + eventIds: ["b"], + isError: false, + }, + { + id: "c", + lane: "tools", + kind: "tool", + start: 2, + end: 3, + eventIds: ["c"], + isError: false, + }, + ]; + expect([ + ...trajectoryFocusEventIds(spans, { start: 1.5, end: 2.5 }), + ]).toEqual(["b", "c"]); + }); + + it("zoomDomain shrinks around the anchor and stays inside the full domain", () => { + const next = zoomDomain({ + fullStart: 0, + fullEnd: 100, + domainStart: 0, + domainEnd: 100, + anchorFraction: 0.5, + zoomFactor: 0.5, + minDomain: 10, + }); + expect(next.end - next.start).toBe(50); + expect(next.start).toBeGreaterThanOrEqual(0); + expect(next.end).toBeLessThanOrEqual(100); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/trajectoryTimeline.ts b/dashboard/src/pages/Chat/utils/trajectoryTimeline.ts new file mode 100644 index 00000000..2370ef34 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/trajectoryTimeline.ts @@ -0,0 +1,231 @@ +import type { TrajectoryEvent } from "../../../api/modules/trajectory"; +import { laneForKind, type TrajectoryLane } from "./trajectoryModel"; + +export type SwimlaneMode = "sequence" | "duration" | "actual"; + +export interface SwimlaneSpan { + id: string; + lane: TrajectoryLane; + kind: string; + start: number; + end: number; + eventIds: string[]; + isError: boolean; +} + +export interface TrajectoryTimeRange { + start: number; + end: number; +} + +export function orderedRange(a: number, b: number): TrajectoryTimeRange { + return a <= b ? { start: a, end: b } : { start: b, end: a }; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function trajectoryFocusEventIds( + spans: readonly SwimlaneSpan[], + range: TrajectoryTimeRange, +): ReadonlySet { + const ids = new Set(); + for (const span of spans) { + if (span.start <= range.end && span.end >= range.start) { + for (const eventId of span.eventIds) { + ids.add(eventId); + } + } + } + return ids; +} + +export function zoomDomain(args: { + fullStart: number; + fullEnd: number; + domainStart: number; + domainEnd: number; + anchorFraction: number; + zoomFactor: number; + minDomain: number; +}): TrajectoryTimeRange { + const { + fullStart, + fullEnd, + domainStart, + domainEnd, + anchorFraction, + zoomFactor, + minDomain, + } = args; + const fullWidth = fullEnd - fullStart; + const currentWidth = domainEnd - domainStart; + const nextWidth = clamp(currentWidth * zoomFactor, minDomain, fullWidth); + const anchorTime = domainStart + anchorFraction * currentWidth; + let start = anchorTime - anchorFraction * nextWidth; + let end = start + nextWidth; + if (start < fullStart) { + start = fullStart; + end = fullStart + nextWidth; + } + if (end > fullEnd) { + end = fullEnd; + start = fullEnd - nextWidth; + } + return { start, end }; +} + +export function panDomain(args: { + fullStart: number; + fullEnd: number; + domainStart: number; + domainEnd: number; + deltaFraction: number; +}): TrajectoryTimeRange { + const { fullStart, fullEnd, domainStart, domainEnd, deltaFraction } = args; + const domainWidth = domainEnd - domainStart; + const delta = deltaFraction * domainWidth; + let start = domainStart + delta; + let end = domainEnd + delta; + if (start < fullStart) { + end += fullStart - start; + start = fullStart; + } + if (end > fullEnd) { + start -= end - fullEnd; + end = fullEnd; + } + return { start, end }; +} + +function clampDuration(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function payloadSize(value: unknown): number { + if (value == null) return 0; + if (typeof value === "string") return value.length; + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} + +/** + * When wall-clock durations were never recorded (legacy rows with ``ts === 0``), + * size Duration-mode spans from payload bulk so the toggle is still visible. + */ +function estimatedDurationMs(event: TrajectoryEvent): number { + const summaryLen = event.summary?.length ?? 0; + switch (event.kind) { + case "tool": { + const size = + payloadSize(event.payload.result) + + payloadSize(event.payload.args) + + summaryLen; + return clampDuration(80 + size, 80, 6_000); + } + case "assistant": { + const size = + payloadSize(event.payload.content) + + payloadSize(event.payload.thinking) + + summaryLen; + return clampDuration(120 + size * 2, 120, 8_000); + } + case "context": + case "system": + case "compacted": + return clampDuration( + 40 + payloadSize(event.payload.content) + summaryLen, + 40, + 4_000, + ); + case "user": + default: + return clampDuration(40 + summaryLen, 40, 2_000); + } +} + +function eventDuration( + event: TrajectoryEvent, + mode: SwimlaneMode, + next: TrajectoryEvent | undefined, +): number { + if (mode === "sequence") return 1; + if (mode === "duration") { + const key = event.kind === "tool" ? "tool_duration_ms" : "llm_duration_ms"; + const raw = event.payload[key]; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw; + // Wall-clock gap between successive events (seconds → ms). + // Sub-50ms gaps are typical of burst stamping and look identical to + // sequence mode — treat them as missing and estimate from payload bulk. + if (next != null && event.ts > 0 && next.ts > event.ts) { + const gapMs = (next.ts - event.ts) * 1000; + if (gapMs >= 50) return gapMs; + } + return estimatedDurationMs(event); + } + return 1; +} + +function actualEnd(events: TrajectoryEvent[], index: number): number { + const event = events[index]; + const start = event?.ts ?? 0; + const key = event?.kind === "tool" ? "tool_duration_ms" : "llm_duration_ms"; + const duration = event?.payload[key]; + if ( + typeof duration === "number" && + Number.isFinite(duration) && + duration > 0 + ) { + return start + duration; + } + const next = events[index + 1]; + if (next != null && next.ts > start) return next.ts; + return start + 1; +} + +/** + * Project each ledger event into a discrete three-lane span (DSH-style). + * Consecutive same-lane events stay separate blocks — never merged. + */ +export function deriveSwimlaneSpans( + events: TrajectoryEvent[], + mode: SwimlaneMode, +): SwimlaneSpan[] { + if (events.length === 0) return []; + + if (mode === "actual") { + return events.map((event, index) => { + const start = event.ts; + const end = actualEnd(events, index); + return { + id: event.event_id, + lane: laneForKind(event.kind), + kind: event.kind, + start, + end: end > start ? end : start + 1, + eventIds: [event.event_id], + isError: event.is_error, + }; + }); + } + + let cursor = 0; + return events.map((event, index) => { + const start = cursor; + const end = start + eventDuration(event, mode, events[index + 1]); + cursor = end; + return { + id: event.event_id, + lane: laneForKind(event.kind), + kind: event.kind, + start, + end, + eventIds: [event.event_id], + isError: event.is_error, + }; + }); +} diff --git a/dashboard/src/pages/Chat/utils/turnTimeline.test.ts b/dashboard/src/pages/Chat/utils/turnTimeline.test.ts new file mode 100644 index 00000000..74cd1055 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/turnTimeline.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "../hooks/useChat"; +import { groupConsecutiveAssistantMessages } from "./messageGrouping"; +import { + buildTurnTimelineItems, + mergeTurnPositions, + readElementDirection, + resolveActiveTurnId, + resolveFollowPinnedTurnId, + tickVisualForDistance, + truncatePreviewText, + visibleTurnWindow, +} from "./turnTimeline"; + +function msg( + role: ChatMessage["role"], + id: string, + extra?: Partial, +): ChatMessage { + return { + id, + role, + content: extra?.content ?? "", + status: "done", + timestamp: Date.now(), + ...extra, + }; +} + +const copy = { + userFallback: "(empty)", + emptyAssistant: "(no reply)", + runningAssistant: "(generating)", +}; + +describe("truncatePreviewText", () => { + it("falls back when empty", () => { + expect(truncatePreviewText([], "fallback")).toBe("fallback"); + }); + + it("truncates long text with ellipsis", () => { + const long = "a".repeat(300); + const out = truncatePreviewText([long], "x", 20); + expect(out.endsWith("...")).toBe(true); + expect(out.length).toBeLessThanOrEqual(20); + }); + + it("keeps at most two paragraphs", () => { + const out = truncatePreviewText(["one\n\ntwo\n\nthree"], "x", 220, 2); + expect(out).toBe("one\ntwo"); + }); +}); + +describe("tickVisualForDistance", () => { + it("returns idle when no focus", () => { + expect(tickVisualForDistance(0, undefined)).toMatchObject({ + tone: "idle", + scaleX: 1, + }); + }); + + it("peaks at focus and cascades neighbors", () => { + expect(tickVisualForDistance(3, 3).scaleX).toBe(2.6); + expect(tickVisualForDistance(2, 3).scaleX).toBe(1.7); + expect(tickVisualForDistance(1, 3).scaleX).toBe(1.25); + expect(tickVisualForDistance(0, 3).scaleX).toBe(1); + }); +}); + +describe("resolveActiveTurnId", () => { + it("picks the visible row closest to the viewport top", () => { + const id = resolveActiveTurnId({ + scrollOffsetPx: 100, + viewportHeightPx: 400, + positions: [ + { messageId: "a", start: 0, end: 80 }, + { messageId: "b", start: 120, end: 200 }, + { messageId: "c", start: 500, end: 580 }, + ], + }); + expect(id).toBe("b"); + }); + + it("falls back to the last row above the viewport", () => { + const id = resolveActiveTurnId({ + scrollOffsetPx: 300, + viewportHeightPx: 100, + positions: [ + { messageId: "a", start: 0, end: 50 }, + { messageId: "b", start: 100, end: 150 }, + { messageId: "c", start: 500, end: 550 }, + ], + }); + expect(id).toBe("b"); + }); +}); + +describe("buildTurnTimelineItems", () => { + it("indexes user turns with assistant previews", () => { + const messages = [ + msg("user", "u1", { content: "hello" }), + msg("assistant", "a1", { content: "world" }), + msg("user", "u2", { content: "again" }), + msg("assistant", "a2", { content: "ok", status: "streaming" }), + ]; + const groups = groupConsecutiveAssistantMessages(messages); + const items = buildTurnTimelineItems(messages, groups, copy, { + isStreaming: true, + }); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + messageId: "u1", + userPreview: "hello", + assistantPreview: "world", + assistantPreviewKind: "text", + isRunning: false, + }); + expect(items[1]).toMatchObject({ + messageId: "u2", + isRunning: true, + }); + }); +}); + +describe("mergeTurnPositions", () => { + it("interpolates missing virtualized turns from neighbors", () => { + const merged = mergeTurnPositions({ + estimatedUnitHeight: 100, + turns: [ + { messageId: "u1", groupIndex: 0 }, + { messageId: "u2", groupIndex: 2 }, + { messageId: "u3", groupIndex: 4 }, + ], + measured: [{ messageId: "u1", start: 0, end: 80 }], + }); + expect(merged[0]).toEqual({ messageId: "u1", start: 0, end: 80 }); + expect(merged[1].start).toBe(200); + expect(merged[2].start).toBe(400); + }); +}); + +describe("resolveFollowPinnedTurnId", () => { + it("pins to the latest turn while following", () => { + expect( + resolveFollowPinnedTurnId([{ messageId: "a" }, { messageId: "b" }], { + following: true, + }), + ).toBe("b"); + expect( + resolveFollowPinnedTurnId([{ messageId: "a" }], { following: false }), + ).toBeUndefined(); + }); +}); + +describe("visibleTurnWindow", () => { + it("returns full range when forceFull", () => { + expect( + visibleTurnWindow({ + count: 100, + scrollTop: 0, + viewportHeight: 100, + forceFull: true, + }), + ).toEqual({ start: 0, end: 100 }); + }); + + it("windows ticks with overscan", () => { + expect( + visibleTurnWindow({ + count: 100, + scrollTop: 140, + viewportHeight: 70, + rowPx: 14, + overscan: 2, + }), + ).toEqual({ start: 8, end: 17 }); + }); +}); + +describe("readElementDirection", () => { + it("reads rtl from document dir", () => { + document.documentElement.setAttribute("dir", "rtl"); + expect(readElementDirection(null)).toBe("rtl"); + document.documentElement.setAttribute("dir", "ltr"); + expect(readElementDirection(null)).toBe("ltr"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/turnTimeline.ts b/dashboard/src/pages/Chat/utils/turnTimeline.ts new file mode 100644 index 00000000..858a6085 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/turnTimeline.ts @@ -0,0 +1,330 @@ +import type { ChatMessage } from "../hooks/useChat"; +import { deriveMessageContent } from "./messageContent"; +import type { MessageGroup } from "./messageGrouping"; + +export const TURN_TIMELINE_MIN_TURNS = 2; +export const TURN_TIMELINE_MIN_WIDTH_PX = 864; +export const TURN_TIMELINE_MAX_PREVIEW_CHARS = 220; +export const TURN_TIMELINE_MAX_PREVIEW_PARAGRAPHS = 2; +export const TURN_TIMELINE_ROW_PX = 14; +/** Window ticks when the rail grows past this many user turns. */ +export const TURN_TIMELINE_VIRTUALIZE_THRESHOLD = 40; +export const TURN_TIMELINE_RAIL_OVERSCAN = 6; +export const TURN_TIMELINE_HOVER_OPEN_DELAY_S = 0.12; +export const TURN_TIMELINE_HOVER_CLOSE_DELAY_S = 0.08; + +export interface TurnTimelineItem { + messageId: string; + groupIndex: number; + userPreview: string; + assistantPreview: string; + assistantPreviewKind: "text" | "empty" | "running"; + isRunning: boolean; +} + +export interface TurnTickVisual { + colorTone: "focus" | "muted"; + opacity: number; + scaleX: number; + tone: "peak" | "near" | "mid" | "idle"; +} + +export interface TurnTimelinePreviewCopy { + userFallback: string; + emptyAssistant: string; + runningAssistant: string; +} + +function splitPreviewParagraphs(text: string, maxParagraphs: number): string[] { + return text + .trim() + .split(/\n\s*\n/u) + .map((part) => part.replace(/\s+/gu, " ").trim()) + .filter(Boolean) + .slice(0, Math.max(1, maxParagraphs)); +} + +export function truncatePreviewText( + texts: string[], + fallback: string, + maxChars = TURN_TIMELINE_MAX_PREVIEW_CHARS, + maxParagraphs = TURN_TIMELINE_MAX_PREVIEW_PARAGRAPHS, +): string { + const paragraphs = splitPreviewParagraphs(texts.join("\n\n"), maxParagraphs); + if (paragraphs.length === 0) return fallback; + const joined = paragraphs.join("\n"); + const limit = Math.max(8, maxChars); + if (joined.length <= limit) return joined; + return `${joined.slice(0, limit - 3).trimEnd()}...`; +} + +export function tickVisualForDistance( + itemIndex: number, + visualFocusItemIndex: number | undefined, +): TurnTickVisual { + if (visualFocusItemIndex === undefined) { + return { colorTone: "muted", opacity: 0.58, scaleX: 1, tone: "idle" }; + } + const distance = Math.abs(itemIndex - visualFocusItemIndex); + if (distance === 0) { + return { colorTone: "focus", opacity: 1, scaleX: 2.6, tone: "peak" }; + } + if (distance === 1) { + return { colorTone: "muted", opacity: 0.86, scaleX: 1.7, tone: "near" }; + } + if (distance === 2) { + return { colorTone: "muted", opacity: 0.72, scaleX: 1.25, tone: "mid" }; + } + return { colorTone: "muted", opacity: 0.58, scaleX: 1, tone: "idle" }; +} + +export function resolveActiveTurnId(args: { + positions: Array<{ messageId: string; start: number; end: number }>; + scrollOffsetPx: number; + viewportHeightPx: number; +}): string | undefined { + const { positions, scrollOffsetPx, viewportHeightPx } = args; + if (positions.length === 0) return undefined; + const viewportStart = Math.max(0, scrollOffsetPx); + const viewportEnd = viewportStart + Math.max(1, viewportHeightPx); + const normalized = positions + .map((row) => { + const start = Math.max(0, row.start); + return { + messageId: row.messageId, + start, + end: Math.max(start, row.end), + }; + }) + .sort( + (a, b) => a.start - b.start || a.messageId.localeCompare(b.messageId), + ); + const visible = normalized.filter( + (row) => row.end >= viewportStart && row.start <= viewportEnd, + ); + if (visible.length > 0) { + return visible.reduce((best, row) => + Math.abs(row.start - viewportStart) < Math.abs(best.start - viewportStart) + ? row + : best, + ).messageId; + } + return ( + [...normalized].reverse().find((row) => row.start <= viewportStart) + ?.messageId ?? + normalized.find((row) => row.start > viewportStart)?.messageId + ); +} + +function messageText(message: ChatMessage): string { + return deriveMessageContent(message).textContent.trim(); +} + +export function buildTurnTimelineItems( + messages: ChatMessage[], + messageGroups: MessageGroup[], + copy: TurnTimelinePreviewCopy, + opts?: { isStreaming?: boolean }, +): TurnTimelineItem[] { + const groupIndexByMessageId = new Map(); + messageGroups.forEach((group, index) => { + for (const msg of group.messages) { + groupIndexByMessageId.set(msg.id, index); + } + }); + + const items: TurnTimelineItem[] = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg.role !== "user") continue; + const groupIndex = groupIndexByMessageId.get(msg.id); + if (groupIndex === undefined) continue; + + let assistant: ChatMessage | undefined; + for (let j = i + 1; j < messages.length; j++) { + const next = messages[j]; + if (next.role === "user") break; + if (next.role === "assistant" && messageText(next)) { + assistant = next; + } + } + + const isLastUser = !messages + .slice(i + 1) + .some((candidate) => candidate.role === "user"); + const isRunning = Boolean( + opts?.isStreaming && + isLastUser && + (!assistant || assistant.status === "streaming"), + ); + + let assistantPreviewKind: TurnTimelineItem["assistantPreviewKind"] = + "empty"; + let assistantPreview = copy.emptyAssistant; + if (assistant && messageText(assistant)) { + assistantPreviewKind = "text"; + assistantPreview = truncatePreviewText( + [messageText(assistant)], + copy.emptyAssistant, + ); + } else if (isRunning) { + assistantPreviewKind = "running"; + assistantPreview = copy.runningAssistant; + } + + items.push({ + messageId: msg.id, + groupIndex, + userPreview: truncatePreviewText([messageText(msg)], copy.userFallback), + assistantPreview, + assistantPreviewKind, + isRunning, + }); + } + return items; +} + +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function alignElementToScrollerTop( + scroller: HTMLElement, + target: HTMLElement, + behavior: ScrollBehavior, +): void { + const top = + scroller.scrollTop + + target.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + if (behavior === "auto") { + scroller.scrollTop = top; + return; + } + scroller.scrollTo({ top, behavior }); +} + +/** Fallback height for virtualized turns that are not mounted yet. */ +export const TURN_TIMELINE_ESTIMATED_UNIT_HEIGHT_PX = 140; + +/** + * Fill in scroll positions for turns whose DOM nodes are not mounted + * (Virtuoso windowing) by interpolating from nearest measured neighbors. + */ +export function mergeTurnPositions(args: { + turns: Array<{ messageId: string; groupIndex: number }>; + measured: Array<{ messageId: string; start: number; end: number }>; + estimatedUnitHeight?: number; +}): Array<{ messageId: string; start: number; end: number }> { + const unit = Math.max( + 1, + args.estimatedUnitHeight ?? TURN_TIMELINE_ESTIMATED_UNIT_HEIGHT_PX, + ); + const measuredById = new Map( + args.measured.map((row) => [row.messageId, row] as const), + ); + const measuredByGroup = new Map< + number, + { start: number; end: number; groupIndex: number } + >(); + for (const turn of args.turns) { + const known = measuredById.get(turn.messageId); + if (!known) continue; + measuredByGroup.set(turn.groupIndex, { + start: known.start, + end: known.end, + groupIndex: turn.groupIndex, + }); + } + + return args.turns.map((turn) => { + const known = measuredById.get(turn.messageId); + if (known) return known; + + let nearest: + | { dist: number; start: number; end: number; groupIndex: number } + | undefined; + for (const candidate of measuredByGroup.values()) { + const dist = Math.abs(candidate.groupIndex - turn.groupIndex); + if (!nearest || dist < nearest.dist) { + nearest = { ...candidate, dist }; + } + } + + if (nearest) { + const delta = (turn.groupIndex - nearest.groupIndex) * unit; + const start = nearest.start + delta; + return { + messageId: turn.messageId, + start, + end: start + unit, + }; + } + + const start = turn.groupIndex * unit; + return { + messageId: turn.messageId, + start, + end: start + unit, + }; + }); +} + +/** Prefer the latest turn while the list is pinned to the bottom / following. */ +export function resolveFollowPinnedTurnId( + turns: Array<{ messageId: string }>, + options: { following: boolean }, +): string | undefined { + if (!options.following || turns.length === 0) return undefined; + return turns[turns.length - 1]?.messageId; +} + +export function resolveFlashTarget(anchor: HTMLElement): HTMLElement { + const bubble = + anchor.querySelector('[class*="userBubble"]') ?? + anchor.querySelector('[class*="messageBubble"]'); + return bubble ?? anchor; +} + +/** Inclusive-exclusive window of tick indices to mount in the rail. */ +export function visibleTurnWindow(args: { + count: number; + scrollTop: number; + viewportHeight: number; + rowPx?: number; + overscan?: number; + forceFull?: boolean; +}): { start: number; end: number } { + const count = Math.max(0, args.count); + if (count === 0) return { start: 0, end: 0 }; + if (args.forceFull) return { start: 0, end: count }; + const rowPx = Math.max(1, args.rowPx ?? TURN_TIMELINE_ROW_PX); + const overscan = Math.max(0, args.overscan ?? TURN_TIMELINE_RAIL_OVERSCAN); + const start = Math.max(0, Math.floor(args.scrollTop / rowPx) - overscan); + const end = Math.min( + count, + Math.ceil((args.scrollTop + Math.max(1, args.viewportHeight)) / rowPx) + + overscan, + ); + return { start, end: Math.max(start, end) }; +} + +export function readElementDirection( + el: Element | null | undefined, +): "ltr" | "rtl" { + if (typeof document === "undefined") return "ltr"; + const withDir = + el && typeof el.closest === "function" ? el.closest("[dir]") : null; + const attr = ( + withDir?.getAttribute("dir") || + document.documentElement.getAttribute("dir") || + "" + ).toLowerCase(); + if (attr === "rtl" || attr === "ltr") return attr; + if (typeof getComputedStyle === "function" && el) { + const computed = getComputedStyle(el).direction.toLowerCase(); + if (computed === "rtl") return "rtl"; + } + return "ltr"; +} diff --git a/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx b/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx index cb721d6d..737f79f4 100644 --- a/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx @@ -111,6 +111,9 @@ export function JobDetailDrawer({ {job.id} + + {job.name || "—"} + {job.enabled ? ( {t("common.enabled")} diff --git a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx index 4a03a626..3918e4dd 100644 --- a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx @@ -20,7 +20,7 @@ import { cronToPreset, presetToCron, } from "./constants"; -import { CRON_PROMPT_MAX_LEN } from "../constants"; +import { CRON_NAME_MAX_LEN, CRON_PROMPT_MAX_LEN } from "../constants"; import { channelFromSessionKey } from "../cronDisplay"; import type { CronJobFormValues } from "../useCronJobs"; import { @@ -173,12 +173,18 @@ export function JobDrawer({ .listInstances() .then((instances) => { setConnectorOptions( - (instances || []).map((i) => ({ - value: i.mcp_server_name, - label: i.display_name?.trim() - ? `${i.display_name} (${i.mcp_server_name})` - : i.mcp_server_name, - })), + (instances || []) + .filter((i) => i.status === "active" && i.has_credentials) + .map((i) => ({ + value: i.mcp_server_name, + label: i.display_name?.trim() + ? `${i.display_name}${ + i.shared && i.owner_display_name + ? ` · ${i.owner_display_name}` + : "" + } (${i.mcp_server_name})` + : i.mcp_server_name, + })), ); }) .catch(() => setConnectorOptions([])) @@ -220,6 +226,27 @@ export function JobDrawer({ )} + + + + ({ style: { paddingLeft: 28 } }), @@ -111,12 +111,33 @@ export const createColumns = ( ); }, }, + { + title: handlers.t("cronJobs.col.name"), + dataIndex: "name", + key: "name", + width: 180, + fixed: sticky ? "left" : undefined, + ellipsis: true, + render: (name: string, record: CronJob) => { + const text = name?.trim() || record.id; + return ( + + + + ); + }, + }, { title: handlers.t("common.enabled"), dataIndex: "enabled", key: "enabled", width: 100, - fixed: sticky ? "left" : undefined, render: (enabled: boolean) => ( { const taskType = record.task_type === "text" ? "text" : "agent"; return ( @@ -169,7 +191,7 @@ export const createColumns = ( { title: handlers.t("cronJobs.col.prompt"), key: "prompt", - width: 200, + width: 240, ellipsis: true, render: (_: unknown, record: CronJob) => { const content = extractPromptFromJob(record); @@ -252,8 +274,9 @@ export const createColumns = ( { title: handlers.t("cronJobs.action"), key: "action", - width: 200, + width: 220, fixed: sticky ? "right" : undefined, + className: styles.actionCell, render: (_: unknown, record: CronJob) => { const menuItems: MenuProps["items"] = [ { @@ -272,7 +295,7 @@ export const createColumns = ( ]; return ( -
+